diff --git a/backend/api/coworking/reservation.py b/backend/api/coworking/reservation.py index 5281f09ee..4eadf1906 100644 --- a/backend/api/coworking/reservation.py +++ b/backend/api/coworking/reservation.py @@ -6,6 +6,7 @@ from typing import Sequence from datetime import datetime +from ...models.coworking.reservation import GetRoomAvailabilityResponse from backend.models.room import Room from ..authentication import registered_user from ...services.coworking.reservation import ReservationException, ReservationService @@ -15,7 +16,7 @@ ReservationRequest, ReservationPartial, ReservationState, - ReservationMapDetails + ReservationMapDetails, ) __authors__ = ["Kris Jordan, Yuvraj Jain"] @@ -91,3 +92,13 @@ def get_total_hours_study_room_reservations( ) -> str: """Allows a user to know how many hours they have reserved in all study rooms (Excludes CSXL).""" return reservation_svc.get_total_time_user_reservations(subject) + + +@api.get("/rooms/availability", tags=["Coworking"]) +def get_room_availability( + date: datetime | None = None, + subject: User = Depends(registered_user), + reservation_svc: ReservationService = Depends(), +) -> GetRoomAvailabilityResponse: + """Determines the room availability at a given time for a user.""" + return reservation_svc.get_room_availability(subject, date=date) diff --git a/backend/models/coworking/reservation.py b/backend/models/coworking/reservation.py index 2775cfec3..2c2d32478 100644 --- a/backend/models/coworking/reservation.py +++ b/backend/models/coworking/reservation.py @@ -70,3 +70,37 @@ class ReservationDetails(Reservation): extendable: bool = False extendable_at: datetime | None extendable_until: datetime | None + + +# New models for room reservations +class RoomAvailabilityState(str, Enum): + AVAILABLE = "AVAILABLE" + RESERVED = "RESERVED" + YOUR_RESERVATION = "YOUR_RESERVATION" + UNAVAILABLE = "UNAVAILABLE" + + +class GetRoomAvailabilityResponse_Slot(BaseModel): + start_time: datetime + end_time: datetime + + +class GetRoomAvailabilityResponse_RoomAvailability(BaseModel): + state: RoomAvailabilityState + description: str | None = None + + +class GetRoomAvailabilityResponse_Room(BaseModel): + room: str + capacity: int + minimum_reservers: int + availability: dict[ + str, GetRoomAvailabilityResponse_RoomAvailability + ] # [timeslot : availability] + + +class GetRoomAvailabilityResponse(BaseModel): + is_instructor: bool + slot_labels: list[str] + slots: dict[str, GetRoomAvailabilityResponse_Slot] # [timeslot : availability] + rooms: list[GetRoomAvailabilityResponse_Room] diff --git a/backend/services/article.py b/backend/services/article.py index 03c79e65b..620b43fec 100644 --- a/backend/services/article.py +++ b/backend/services/article.py @@ -84,10 +84,9 @@ def get_welcome_overview(self, subject: User | None) -> WelcomeOverview: # Load operating hours now = datetime.now() + coworking_policy = self._policies_svc.policy_for_user(subject) operating_hours = self._operating_hours_svc.schedule( - TimeRange( - start=now, end=now + self._policies_svc.reservation_window(subject) - ) + TimeRange(start=now, end=now + coworking_policy.reservation_window) ) # Load future reservations for a given user. diff --git a/backend/services/coworking/operating_hours.py b/backend/services/coworking/operating_hours.py index 6cf00e72c..a734db415 100644 --- a/backend/services/coworking/operating_hours.py +++ b/backend/services/coworking/operating_hours.py @@ -81,6 +81,7 @@ def create(self, subject: User, time_range: TimeRange) -> OperatingHours: self._permission_svc.enforce( subject, "coworking.operating_hours.create", "coworking/operating_hours" ) + self._session.flush() conflicts = self.schedule(time_range) if len(conflicts) > 0: diff --git a/backend/services/coworking/policy.py b/backend/services/coworking/policy.py index 05ea51c91..3ac656303 100644 --- a/backend/services/coworking/policy.py +++ b/backend/services/coworking/policy.py @@ -1,10 +1,17 @@ """Service that manages policies around the reservation system.""" +from enum import Enum from fastapi import Depends +from sqlalchemy import func, select from sqlalchemy.orm import Session from datetime import timedelta, datetime, time + +from backend.entities.academics.section_member_entity import SectionMemberEntity +from backend.models.roster_role import RosterRole from ...database import db_session from ...models import User +from abc import ABC, abstractmethod +from pydantic import BaseModel __authors__ = ["Kris Jordan, Yuvraj Jain"] __copyright__ = "Copyright 2023-24" @@ -69,66 +76,149 @@ } -class PolicyService: - """RoleService is the access layer to the role data model, its members, and permissions. +class CoworkingPolicyType(Enum): + """ + Determines the different types of coworking policies. - We are carving out a simple service for looking up policies in anticipation of having different policies - for different groups of users (e.g. majors, ambassadors, LAs, etc). + These are mapped to numbers for comparison purposes to determine which policy to use + if multiple policies match, with the greatest number taking the highest priority. """ - def __init__(self): ... + STUDENT = 0 + """Policy for regular users""" + + INSTRUCTOR = 1 + """Policy for regular instructors""" + + +class CoworkingPolicy(BaseModel): - def walkin_window(self, _subject: User) -> timedelta: - """How far into the future can walkins be reserved?""" - return timedelta(minutes=10) + walkin_window: timedelta + """How far into the future can walkins be reserved?""" - def walkin_initial_duration(self, _subject: User) -> timedelta: - """When making a walkin, this sets how long the initial reservation is for.""" - return timedelta(hours=2) + walkin_initial_duration: timedelta + """When making a walkin, this sets how long the initial reservation is for.""" - def reservation_window(self, _subject: User) -> timedelta: - """Returns the number of days in advance the user can make reservations.""" - return timedelta(weeks=1) + reservation_window: timedelta + """Returns the number of days in advance the user can make reservations.""" - def minimum_reservation_duration(self) -> timedelta: - """The minimum amount of time a reservation can be made for.""" - return timedelta(minutes=10) + minimum_reservation_duration: timedelta + """The minimum amount of time a reservation can be made for.""" - def maximum_initial_reservation_duration(self, _subject: User) -> timedelta: - """The maximum amount of time a reservation can be made for before extending.""" - return timedelta(hours=2) + maximum_initial_reservation_duration: timedelta + """The maximum amount of time a reservation can be made for before extending.""" - # Implement and involve in testing once extending a reservation functionality is added. - # def extend_window(self, _subject: User) -> timedelta: - # """When no reservation follows a given reservation, within this period preceeding the end of a reservation the user is able to extend their reservation by an hour.""" - # return timedelta(minutes=15 * -1) + reservation_draft_timeout: timedelta - # def extend_duration(self, _subject: User) -> timedelta: - # return timedelta(hours=1) + reservation_checkin_timeout: timedelta - def reservation_draft_timeout(self) -> timedelta: - return timedelta(minutes=5) + room_reservation_weekly_limit: timedelta + """The maximum amount of hours a student can reserve the study rooms outside of the csxl.""" - def reservation_checkin_timeout(self) -> timedelta: - return timedelta(minutes=10) + allow_overlapping_room_reservations: bool + """Whether or not to allow the user to reserve two rooms at the same time (useful for instructors)""" + + +class PolicyService: + """RoleService is the access layer to the role data model, its members, and permissions. + + We are carving out a simple service for looking up policies in anticipation of having different policies + for different groups of users (e.g. majors, ambassadors, LAs, etc). + """ - def room_reservation_weekly_limit(self) -> timedelta: - """The maximum amount of hours a student can reserve the study rooms outside of the csxl.""" - return timedelta(hours=6) + def __init__(self, session: Session = Depends(db_session)): + self._session = session + # Set policies here + self._policies: dict[CoworkingPolicyType, CoworkingPolicy] = { + CoworkingPolicyType.STUDENT: CoworkingPolicy( + walkin_window=timedelta(minutes=10), + walkin_initial_duration=timedelta(hours=2), + reservation_window=timedelta(weeks=1), + minimum_reservation_duration=timedelta(minutes=10), + maximum_initial_reservation_duration=timedelta(hours=2), + reservation_draft_timeout=timedelta(minutes=5), + reservation_checkin_timeout=timedelta(minutes=10), + room_reservation_weekly_limit=timedelta(hours=6), + allow_overlapping_room_reservations=False, + ), + CoworkingPolicyType.INSTRUCTOR: CoworkingPolicy( + walkin_window=timedelta(minutes=10), + walkin_initial_duration=timedelta(hours=2), + reservation_window=timedelta(weeks=8), + minimum_reservation_duration=timedelta(minutes=10), + maximum_initial_reservation_duration=timedelta(hours=2), + reservation_draft_timeout=timedelta(minutes=5), + reservation_checkin_timeout=timedelta(minutes=10), + room_reservation_weekly_limit=timedelta(hours=168), # 24hrs * 7days + allow_overlapping_room_reservations=True, + ), + } + + def default_policy(self) -> CoworkingPolicy: + """Returns the default policy. + NOTE: At some point, this likely should be phased out. + """ + return self._policies[CoworkingPolicyType.STUDENT] + + def policy_for_user(self, _subject: User) -> CoworkingPolicy: + """Determines the coworking policy to use for a given subject.""" + + # Determine if the user is an instructor for a course. + is_instructor_query = ( + select(func.count()) + .select_from(SectionMemberEntity) + .where( + SectionMemberEntity.user_id == _subject.id, + SectionMemberEntity.member_role == RosterRole.INSTRUCTOR, + ) + ) + is_instructor = self._session.execute(is_instructor_query).scalar() > 0 + if is_instructor: + return self._policies[CoworkingPolicyType.INSTRUCTOR] + + # Otherwise, the user is a regular user. + # NOTE: In the future, this can be extended easily to add extra policies + # for ambassadors, etc. + return self._policies[CoworkingPolicyType.STUDENT] + + # def walkin_window(self, policy: CoworkingPolicy) -> timedelta: + # """How far into the future can walkins be reserved?""" + # return self._policy(_subject).walkin_window + + # def walkin_initial_duration(self, _subject: User) -> timedelta: + # """When making a walkin, this sets how long the initial reservation is for.""" + # return self._policy(_subject).walkin_initial_duration + + # def reservation_window(self, _subject: User) -> timedelta: + # """Returns the number of days in advance the user can make reservations.""" + # return self._policy(_subject).reservation_window + + # def minimum_reservation_duration(self, _subject: User) -> timedelta: + # """The minimum amount of time a reservation can be made for.""" + # return self._policy(_subject).minimum_reservation_duration + + # def maximum_initial_reservation_duration(self, _subject: User) -> timedelta: + # """The maximum amount of time a reservation can be made for before extending.""" + # return self._policy(_subject).maximum_initial_reservation_duration + + # # Implement and involve in testing once extending a reservation functionality is added. + # # def extend_window(self, _subject: User) -> timedelta: + # # """When no reservation follows a given reservation, within this period preceeding the end of a reservation the user is able to extend their reservation by an hour.""" + # # return timedelta(minutes=15 * -1) + + # # def extend_duration(self, _subject: User) -> timedelta: + # # return timedelta(hours=1) + + # def reservation_draft_timeout(self, _subject: User) -> timedelta: + # return self._policy(_subject).reservation_draft_timeout + + # def reservation_checkin_timeout(self, _subject: User) -> timedelta: + # return self._policy(_subject).reservation_checkin_timeout + + # def room_reservation_weekly_limit(self, _subject: User) -> timedelta: + # """The maximum amount of hours a student can reserve the study rooms outside of the csxl.""" + # return self._policy(_subject).room_reservation_weekly_limit def office_hours(self, date: datetime): day = date.weekday() - if day == MONDAY: - return OH_HOURS[MONDAY] - elif day == TUESDAY: - return OH_HOURS[TUESDAY] - elif day == WEDNESDAY: - return OH_HOURS[WEDNESDAY] - elif day == THURSDAY: - return OH_HOURS[THURSDAY] - elif day == FRIDAY: - return OH_HOURS[FRIDAY] - elif day == SATURDAY: - return OH_HOURS[SATURDAY] - else: - return OH_HOURS[SUNDAY] + return OH_HOURS[day] diff --git a/backend/services/coworking/reservation.py b/backend/services/coworking/reservation.py index 40cc96447..f4a2e96e7 100644 --- a/backend/services/coworking/reservation.py +++ b/backend/services/coworking/reservation.py @@ -1,13 +1,30 @@ """Service that manages reservations in the coworking space.""" +import math from fastapi import Depends -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time from random import random from typing import Sequence -from sqlalchemy import or_, and_ +from sqlalchemy import func, or_, and_, select from sqlalchemy.orm import Session, joinedload + +from backend.entities.academics.section_entity import SectionEntity +from backend.entities.academics.section_member_entity import SectionMemberEntity +from backend.entities.academics.term_entity import TermEntity +from backend.models.coworking import availability +from backend.models.roster_role import RosterRole +from ...entities.coworking.operating_hours_entity import OperatingHoursEntity +from ...entities.coworking.reservation_user_table import reservation_user_table +from ...entities.office_hours.office_hours_entity import OfficeHoursEntity from backend.entities.room_entity import RoomEntity +from ...models.coworking.reservation import ( + GetRoomAvailabilityResponse, + GetRoomAvailabilityResponse_Room, + GetRoomAvailabilityResponse_RoomAvailability, + GetRoomAvailabilityResponse_Slot, + RoomAvailabilityState, +) from backend.models.room_details import RoomDetails from ...database import db_session from ...models.user import User, UserIdentity @@ -28,7 +45,7 @@ from ...entities import UserEntity from ...entities.coworking import ReservationEntity, SeatEntity from .seat import SeatService -from .policy import PolicyService +from .policy import OH_HOURS, PolicyService from .operating_hours import OperatingHoursService from ..permission import PermissionService @@ -121,10 +138,12 @@ def get_current_reservations_for_user( f"user/{focus.id}", ) + coworking_policy = self._policy_svc.policy_for_user(subject) + now = datetime.now() time_range = TimeRange( start=now - timedelta(days=1), - end=now + self._policy_svc.reservation_window(focus), + end=now + coworking_policy.reservation_window, ) if state: @@ -202,6 +221,7 @@ def _check_user_reservation_duration( True if a user has >= 6 total hours reserved False if a user has exceeded the limit """ + coworking_policy = self._policy_svc.policy_for_user(user) reservations = self.get_current_reservations_for_user(user, user) total_duration = timedelta() total_duration += bounds.end - bounds.start @@ -209,7 +229,7 @@ def _check_user_reservation_duration( for reservation in reservations: if reservation.room: total_duration += reservation.end - reservation.start - if total_duration > self._policy_svc.room_reservation_weekly_limit(): + if total_duration > coworking_policy.room_reservation_weekly_limit: return False return True @@ -627,20 +647,21 @@ def _state_transition_reservation_entities_by_time( Returns: Sequence[ReservationEntity] - All ReservationEntities that were not state transitioned. """ + # TODO: Potentially refactor to avoid using a default policy + coworking_policy = self._policy_svc.default_policy() valid: list[ReservationEntity] = [] dirty = False for reservation in reservations: if ( reservation.state == ReservationState.DRAFT - and reservation.created_at - + self._policy_svc.reservation_draft_timeout() + and reservation.created_at + coworking_policy.reservation_draft_timeout < cutoff ): reservation.state = ReservationState.CANCELLED dirty = True elif ( reservation.state == ReservationState.CONFIRMED - and reservation.start + self._policy_svc.reservation_checkin_timeout() + and reservation.start + coworking_policy.reservation_checkin_timeout < cutoff ): reservation.state = ReservationState.CANCELLED @@ -671,6 +692,9 @@ def seat_availability( Returns: Sequence[SeatAvailability]: All seat availability ordered by nearest and longest available. """ + # TODO: Potentially refactor to avoid using a default policy + coworking_policy = self._policy_svc.default_policy() + # No seats are available in the past now = datetime.now() if bounds.end <= now: @@ -684,7 +708,7 @@ def seat_availability( MINUMUM_RESERVATION_EPSILON = timedelta(minutes=1) if ( bounds.duration() - < self._policy_svc.minimum_reservation_duration() + < coworking_policy.minimum_reservation_duration - MINUMUM_RESERVATION_EPSILON ): return [] @@ -725,7 +749,7 @@ def seat_availability( available_seats: list[SeatAvailability] = list( self._prune_seats_below_availability_threshold( list(seat_availability_dict.values()), - self._policy_svc.minimum_reservation_duration() + coworking_policy.minimum_reservation_duration - MINUMUM_RESERVATION_EPSILON, ) ) @@ -771,11 +795,16 @@ def draft_reservation( * Limit users and seats counts to policy * Clean-up / Refactor Implementation """ + # Determine the coworking policy to use based on the subject + coworking_policy = self._policy_svc.policy_for_user(subject) + # For the time being, reservations are limited to one user. As soon as # possible, we'd like to add multi-user reservations so that pairs and teams # can be simplified. - if len(request.users) > 1: - raise NotImplementedError("Multi-user reservations not yet supproted.") + if len(request.users) > 1 and len(request.seats) > 0: + raise NotImplementedError( + "Multi-user reservations for seats not yet supproted." + ) # Enforce Reservation Draft Permissions if subject.id not in [user.id for user in request.users]: @@ -793,13 +822,13 @@ def draft_reservation( now = datetime.now() start = request.start if request.start >= now else now - is_walkin = abs(start - now) < self._policy_svc.walkin_window(subject) + is_walkin = abs(start - now) < coworking_policy.walkin_window # Bound end to policy limits for duration of a reservation if is_walkin: - max_length = self._policy_svc.walkin_initial_duration(subject) + max_length = coworking_policy.walkin_initial_duration else: - max_length = self._policy_svc.maximum_initial_reservation_duration(subject) + max_length = coworking_policy.maximum_initial_reservation_duration end_limit = start + max_length end = request.end if request.end <= end_limit else end_limit @@ -812,6 +841,20 @@ def draft_reservation( raise ReservationException( "Oops! Looks like you've reached your weekly study room reservation limit" ) + room = self._session.get(RoomEntity, request.room.id) + if not room: + raise ReservationException( + "You cannot create a reservation for a room that does not exist." + ) + minimum_reservers = math.floor(room.capacity / 2) + if len(request.users) < minimum_reservers: + raise ReservationException( + f"You must reserve this room for at least {minimum_reservers} people." + ) + if len(request.users) > room.capacity: + raise ReservationException( + f"You must reserve this room for at most {room.capacity} people." + ) # Fetch User entities for all requested in reservation user_entities = ( @@ -834,7 +877,10 @@ def draft_reservation( ) nonconflicting = bounds.subtract(conflict) - if len(nonconflicting) >= 1: + if ( + len(nonconflicting) >= 1 + or coworking_policy.allow_overlapping_room_reservations + ): bounds = nonconflicting[0] else: raise ReservationException( @@ -877,10 +923,21 @@ def draft_reservation( seat_entities = [self._session.get(SeatEntity, seat_availability[0].id)] bounds = seat_availability[0].availability[0] else: - # Prevent double booking a room + # Handle room reservations conflicts conflicts = self._fetch_conflicting_room_reservations(request) if len(conflicts) > 0: raise ReservationException("The requested room is no longer available.") + # Check against existing office hours + office_hours_query = select(OfficeHoursEntity).where( + OfficeHoursEntity.room_id == request.room.id, + OfficeHoursEntity.start_time < bounds.end, + OfficeHoursEntity.end_time > bounds.start, + ) + office_hours_results = self._session.scalars(office_hours_query).all() + if len(office_hours_results) > 0: + raise ReservationException( + "Cannot reserve a room used for office hours." + ) draft = ReservationEntity( state=ReservationState.DRAFT, @@ -1188,3 +1245,323 @@ def _fetch_conflicting_room_reservations( ) .all() ) + + # New room reservation functionality + + def get_room_availability( + self, subject: User, date: datetime | None + ) -> GetRoomAvailabilityResponse: + """Determines the room availability for a given user.""" + # Determine whether or not the user is an instructor + is_instructor = self._user_is_instructor(subject) + + # Convenience functions to get the start and end of either the selected date + # or the current date if no selection was provided. + today = date.date() if date else datetime.now().date() + start_of_day = datetime.combine(today, time.min) + end_of_day = datetime.combine(today, time.max) + + # Get the operating hours for the current day. + operating_hours_query = select(OperatingHoursEntity).where( + OperatingHoursEntity.start < end_of_day, + OperatingHoursEntity.end > start_of_day, + ) + operating_hours = self._session.scalars(operating_hours_query).all() + + # If no operating hours exist, return no available slots for the day. + if ( + len(operating_hours) == 0 + or datetime.combine(datetime.now().date(), time.max) > end_of_day + ): + return GetRoomAvailabilityResponse( + is_instructor=is_instructor, slot_labels=[], slots={}, rooms=[] + ) + + # Determine all of the possible reservations slots between the start and end + # of the operating hours + operating_hours_open = min(oh.start for oh in operating_hours) + operating_hours_close = max(oh.end for oh in operating_hours) + + # Generate 30-minute time slots between open and close times + # Note: `slots` are stored in the format (label, start_time, end_time) + slots: list[tuple[str, datetime, datetime]] = [] + # Note: The current time is either the start of the day (if a reservation is + # overlapping from a previous day) or is the opening time rounded back to the + # start of the hour. + current_time = max(operating_hours_open.replace(minute=0), start_of_day) + end_time_cutoff = min(end_of_day, operating_hours_close) + while current_time < end_time_cutoff: + # Determine the readable label for the slot + hour = ( + current_time.hour if current_time.hour <= 12 else current_time.hour - 12 + ) + hour = 12 if hour == 0 else hour + minute = current_time.minute + period = "a" if current_time.hour < 12 else "p" + slot_label = f"{hour}:{minute:02d}{period}" + # If the current time is somewhere between a 30min interval, we want to adjust + # the current time so that it is exactly on the next 30min mark for the next + # iteration. Otherwise, we can just move the time by 30min. + offset_from_half_hr = current_time.minute % 30 + minutes_adjustment = ( + 30 if offset_from_half_hr == 0 else 30 - offset_from_half_hr + ) + end_time = current_time + timedelta(minutes=minutes_adjustment) + # Add the slot + slots.append( + ( + slot_label, + current_time.replace(second=0, microsecond=0), + end_time.replace(second=0, microsecond=0), + ) + ) + # Adjust the current time for the next iteration + current_time = end_time + + # Get all reservable rooms + reservable_rooms = self._get_reservable_rooms() + + # Keep track of room reservation data + rooms: list[GetRoomAvailabilityResponse_Room] = [] + + # We now want to determine the availability for all reservable rooms over every timeslot. + # The traditional solution to this problem would produce O(rooms * slots) number of queries + # for reservations, so the solution below optimizes this by fetching all reservations that + # might match the timeslot range at once in buld, reducing the number of queries to just 3. + # The rest of the logic is completed in memory. + + # Prepare data for bulk queries + room_ids = [room.id for room in reservable_rooms] + slot_time_ranges = [(slot_start, slot_end) for _, slot_start, slot_end in slots] + + # First query: Get all user reservations for the current user across all rooms and time slots + user_reservations_query = ( + select(ReservationEntity, reservation_user_table.c.user_id) + .join( + reservation_user_table, + ReservationEntity.id == reservation_user_table.c.reservation_id, + ) + .where( + ReservationEntity.room_id.in_(room_ids), + ReservationEntity.state.in_( + [ + ReservationState.CONFIRMED, + ReservationState.CHECKED_IN, + ReservationState.CHECKED_OUT, + ] + ), + reservation_user_table.c.user_id == subject.id, + or_( + *[ + and_( + ReservationEntity.start < slot_end, + ReservationEntity.end > slot_start, + ) + for slot_start, slot_end in slot_time_ranges + ] + ), + ) + ) + user_reservation_results = self._session.execute(user_reservations_query).all() + + # Second query: Get all general reservations across all rooms and time slots + general_reservations_query = select(ReservationEntity).where( + ReservationEntity.room_id.in_(room_ids), + ReservationEntity.state.in_( + [ + ReservationState.CONFIRMED, + ReservationState.CHECKED_IN, + ReservationState.CHECKED_OUT, + ] + ), + or_( + *[ + and_( + ReservationEntity.start < slot_end, + ReservationEntity.end > slot_start, + ) + for slot_start, slot_end in slot_time_ranges + ] + ), + ) + general_reservation_results = self._session.scalars( + general_reservations_query + ).all() + + # Third query: Get all of the office hours across all rooms and time slots + office_hours_query = select(OfficeHoursEntity).where( + OfficeHoursEntity.room_id.in_(room_ids), + or_( + *[ + and_( + OfficeHoursEntity.start_time < slot_end, + OfficeHoursEntity.end_time > slot_start, + ) + for slot_start, slot_end in slot_time_ranges + ] + ), + ) + office_hours_results = self._session.scalars(office_hours_query).all() + + # Now, to make checking for whether or not a user reservation or general reservation + # exists for a combination of a room and timeslot, we will create a lookup dictionary. + # Key format: (room_id, slot_label) : ReservationEntity + user_reservations_lookup: dict[tuple[str, str], ReservationEntity] = {} + user_reservations_timeslots: set[str] = set() + general_reservations_lookup: dict[tuple[str, str], ReservationEntity] = {} + office_hours_lookup: dict[tuple[str, str], ReservationEntity] = {} + + # Build up user reservations lookup table + for reservation, _ in user_reservation_results: + for slot_label, slot_start, slot_end in slots: + if ( + reservation.start.replace(second=0, microsecond=0) < slot_end + and reservation.end.replace(second=0, microsecond=0) > slot_start + ): + room_slot_key = (reservation.room_id, slot_label) + user_reservations_lookup[room_slot_key] = reservation + user_reservations_timeslots.add(slot_label) + + # Build up general reservations lookup table + for reservation in general_reservation_results: + for slot_label, slot_start, slot_end in slots: + if ( + reservation.start.replace(second=0, microsecond=0) < slot_end + and reservation.end.replace(second=0, microsecond=0) > slot_start + ): + room_slot_key = (reservation.room_id, slot_label) + general_reservations_lookup[room_slot_key] = reservation + + # Build up office hours lookup table + for office_hours in office_hours_results: + for slot_label, slot_start, slot_end in slots: + if ( + office_hours.start_time < slot_end + and office_hours.end_time > slot_start + ): + room_slot_key = (office_hours.room_id, slot_label) + office_hours_lookup[room_slot_key] = office_hours + + # For each reservable room and each timeslot, determine whether or not the room + # is available for the timeslot. + for reservable_room in reservable_rooms: + # Availability for the room, stored in the form [timeslot : avilability] + room_availability: dict[ + str, GetRoomAvailabilityResponse_RoomAvailability + ] = {} + for slot_label, slot_start, slot_end in slots: + now = datetime.now() + # First, make sure that the time slot is within valid operating hours - + # otherwise, the reservation should be made unavailable. + if ( + not any( + oh.start <= slot_start and oh.end >= slot_end + for oh in operating_hours + ) + or now >= slot_start + ): + room_availability[slot_label] = ( + GetRoomAvailabilityResponse_RoomAvailability( + state=RoomAvailabilityState.UNAVAILABLE + ) + ) + continue + + # Check if user has a reservation for this room and time slot + room_slot_key = (reservable_room.id, slot_label) + if room_slot_key in user_reservations_lookup: + room_availability[slot_label] = ( + GetRoomAvailabilityResponse_RoomAvailability( + state=RoomAvailabilityState.YOUR_RESERVATION + ) + ) + continue + + # Check if a user has a reservation for another room in the timeslot or if + # office hours are occurring in that timeslot + if ( + slot_label in user_reservations_timeslots + or room_slot_key in office_hours_lookup + ): + room_availability[slot_label] = ( + GetRoomAvailabilityResponse_RoomAvailability( + state=RoomAvailabilityState.UNAVAILABLE + ) + ) + continue + + # Check if any reservation exists for this room and time slot + day_of_week = now.weekday() + office_hour_policy_for_day: list[tuple[time, time]] = ( + OH_HOURS[day_of_week][reservable_room.id] + if reservable_room.id in OH_HOURS[day_of_week] + else [] + ) + + office_hour_policy_time_ranges = [ + TimeRange( + start=datetime.combine(now.date(), start_time), + end=datetime.combine(now.date(), end_time), + ) + for start_time, end_time in office_hour_policy_for_day + ] + policy_conflict = any( + slot_start < time_range.end and slot_end >= time_range.start + for time_range in office_hour_policy_time_ranges + ) + if room_slot_key in general_reservations_lookup or policy_conflict: + room_availability[slot_label] = ( + GetRoomAvailabilityResponse_RoomAvailability( + state=RoomAvailabilityState.RESERVED + ) + ) + continue + + # Otherwise, the room should be available. + room_availability[slot_label] = ( + GetRoomAvailabilityResponse_RoomAvailability( + state=RoomAvailabilityState.AVAILABLE + ) + ) + # Finalize the availability for the room + room = GetRoomAvailabilityResponse_Room( + room=reservable_room.id, + capacity=reservable_room.capacity, + minimum_reservers=math.ceil(reservable_room.capacity / 2), + availability=room_availability, + ) + rooms.append(room) + + # Parse the slot labels + slot_labels = [] + slot_data: dict[str, GetRoomAvailabilityResponse_Slot] = {} + for slot_label, slot_start, slot_end in slots: + slot_labels.append(slot_label) + slot_data[slot_label] = GetRoomAvailabilityResponse_Slot( + start_time=slot_start, end_time=slot_end + ) + + return GetRoomAvailabilityResponse( + is_instructor=is_instructor, + slot_labels=slot_labels, + slots=slot_data, + rooms=rooms, + ) + + def _user_is_instructor(self, subject: User) -> bool: + # Determine whether or not the subject is an instructor + now = datetime.now() + instructor_query = ( + select(SectionMemberEntity) + .join(SectionMemberEntity.section) + .join(SectionEntity.term) + .where( + SectionMemberEntity.user_id == subject.id, + SectionMemberEntity.member_role == RosterRole.INSTRUCTOR, + TermEntity.start <= now, + TermEntity.end >= now, + ) + ) + instructor_query_result = self._session.scalars(instructor_query).all() + is_instructor = len(instructor_query_result) > 0 + return is_instructor diff --git a/backend/services/coworking/status.py b/backend/services/coworking/status.py index 1eefac922..b9ada7d29 100644 --- a/backend/services/coworking/status.py +++ b/backend/services/coworking/status.py @@ -33,6 +33,8 @@ def __init__( def get_coworking_status(self, subject: User) -> Status: """All-in-one endpoint for a user to simultaneously get their own upcoming reservations and current status of the XL.""" + coworking_policy = self._policies_svc.policy_for_user(subject) + my_reservations = self._reservation_svc.get_current_reservations_for_user( subject, subject ) @@ -41,8 +43,8 @@ def get_coworking_status(self, subject: User) -> Status: walkin_window = TimeRange( start=now, end=now - + self._policies_svc.walkin_window(subject) - + 3 * self._policies_svc.walkin_initial_duration(subject), + + coworking_policy.walkin_window + + 3 * coworking_policy.walkin_initial_duration, # We triple walkin duration for end bounds to find seats not pre-reserved later. If XL stays # relatively open, the walkin could then more likely be extended while it is not busy. # This also prioritizes _not_ placing walkins in reservable seats. @@ -53,9 +55,7 @@ def get_coworking_status(self, subject: User) -> Status: ) operating_hours = self._operating_hours_svc.schedule( - TimeRange( - start=now, end=now + self._policies_svc.reservation_window(subject) - ) + TimeRange(start=now, end=now + coworking_policy.reservation_window) ) return Status( diff --git a/backend/test/services/coworking/fixtures.py b/backend/test/services/coworking/fixtures.py index cdcd78561..79c605478 100644 --- a/backend/test/services/coworking/fixtures.py +++ b/backend/test/services/coworking/fixtures.py @@ -51,9 +51,9 @@ def seat_svc(session: Session): @pytest.fixture() -def policy_svc(): +def policy_svc(session: Session): """CoworkingPolicyService fixture.""" - return PolicyService() + return PolicyService(session) @pytest.fixture() diff --git a/backend/test/services/coworking/reservation/draft_test.py b/backend/test/services/coworking/reservation/draft_test.py index 580459bc8..b3369d43a 100644 --- a/backend/test/services/coworking/reservation/draft_test.py +++ b/backend/test/services/coworking/reservation/draft_test.py @@ -75,6 +75,7 @@ def test_draft_reservation_in_past( def test_draft_reservation_beyond_walkin_limit(reservation_svc: ReservationService): """Walkin time limit should be bounded by PolicyService#walkin_initial_duration""" + coworking_policy = reservation_svc._policy_svc.policy_for_user(user_data.user) reservation = reservation_svc.draft_reservation( user_data.user, reservation_data.test_request( @@ -82,15 +83,14 @@ def test_draft_reservation_beyond_walkin_limit(reservation_svc: ReservationServi "users": [UserIdentity(**user_data.user.model_dump())], "start": reservation_data.reservation_1.end, "end": reservation_data.reservation_1.end - + reservation_svc._policy_svc.walkin_initial_duration(user_data.user) + + coworking_policy.walkin_initial_duration + timedelta(minutes=10), } ), ) assert_equal_times(reservation_data.reservation_1.end, reservation.start) assert_equal_times( - reservation_data.reservation_1.end - + reservation_svc._policy_svc.walkin_initial_duration(user_data.user), + reservation_data.reservation_1.end + coworking_policy.walkin_initial_duration, reservation.end, ) @@ -138,9 +138,8 @@ def test_draft_reservation_seat_availability_truncated( def test_draft_reservation_future(reservation_svc: ReservationService): """When a reservation is in the future, it has longer limits.""" - future_reservation_limit = ( - reservation_svc._policy_svc.maximum_initial_reservation_duration(user_data.user) - ) + coworking_policy = reservation_svc._policy_svc.policy_for_user(user_data.user) + future_reservation_limit = coworking_policy.maximum_initial_reservation_duration start = operating_hours_data.future.start end = operating_hours_data.future.start + future_reservation_limit reservation = reservation_svc.draft_reservation( @@ -367,7 +366,7 @@ def test_draft_reservation_room_no_time_conflict_before( end = reservation_data.reservation_6.start conflict_draft = ReservationRequest( seats=[], - room=room_data.group_b, + room=room_data.pair_a, start=start, end=end, users=[user_data.ambassador], @@ -386,7 +385,7 @@ def test_draft_reservation_room_no_time_conflict_after( end = reservation_data.reservation_6.end + timedelta(minutes=30) conflict_draft = ReservationRequest( seats=[], - room=room_data.group_b, + room=room_data.pair_a, start=start, end=end, users=[user_data.ambassador], @@ -405,7 +404,7 @@ def test_draft_reservation_different_room_time_conflict( end = reservation_data.reservation_6.end conflict_draft = ReservationRequest( seats=[], - room=room_data.group_b, # Existing test reservation is group_a + room=room_data.pair_a, # Existing test reservation is group_a start=start, end=end, users=[user_data.ambassador], @@ -420,7 +419,7 @@ def test_draft_reservation_crosses_weekly_limit( reservation_svc: ReservationService, time: dict[str, datetime] ): user_data.user.accepted_community_agreement = True - + # Make filler reservations to reach weekly limit temp_draft_1 = ReservationRequest( seats=[], @@ -430,9 +429,7 @@ def test_draft_reservation_crosses_weekly_limit( users=[user_data.user], ) - reservation_svc.draft_reservation( - user_data.user, temp_draft_1 - ) + reservation_svc.draft_reservation(user_data.user, temp_draft_1) temp_draft_2 = ReservationRequest( seats=[], @@ -442,9 +439,7 @@ def test_draft_reservation_crosses_weekly_limit( users=[user_data.user], ) - reservation_svc.draft_reservation( - user_data.user, temp_draft_2 - ) + reservation_svc.draft_reservation(user_data.user, temp_draft_2) exceed_limit_draft = ReservationRequest( seats=[], @@ -455,6 +450,4 @@ def test_draft_reservation_crosses_weekly_limit( ) with pytest.raises(ReservationException): - reservation_svc.draft_reservation( - user_data.user, exceed_limit_draft - ) \ No newline at end of file + reservation_svc.draft_reservation(user_data.user, exceed_limit_draft) diff --git a/backend/test/services/coworking/reservation/seat_availability_test.py b/backend/test/services/coworking/reservation/seat_availability_test.py index 3e1628427..8530ca048 100644 --- a/backend/test/services/coworking/reservation/seat_availability_test.py +++ b/backend/test/services/coworking/reservation/seat_availability_test.py @@ -71,8 +71,9 @@ def test_seat_availability_truncate_start( policy_svc: PolicyService, time: dict[str, datetime], ): + coworking_policy = policy_svc.default_policy() recent_past_to_five_minutes = TimeRange( - start=time[NOW] - policy_svc.minimum_reservation_duration(), + start=time[NOW] - coworking_policy.minimum_reservation_duration, end=time[NOW] + FIVE_MINUTES, ) available_seats = reservation_svc.seat_availability( @@ -138,9 +139,10 @@ def test_seat_availability_xl_closing_soon( reservation_svc: ReservationService, policy_svc: PolicyService ): """When the XL is open and upcoming walkins are available, but the closing hour is under default walkin duration.""" + coworking_policy = policy_svc.default_policy() near_closing = TimeRange( start=operating_hours_data.tomorrow.end - - (policy_svc.minimum_reservation_duration() - 2 * ONE_MINUTE), + - (coworking_policy.minimum_reservation_duration - 2 * ONE_MINUTE), end=operating_hours_data.tomorrow.end, ) available_seats = reservation_svc.seat_availability(seat_data.seats, near_closing) diff --git a/backend/test/services/coworking/reservation/state_transition_test.py b/backend/test/services/coworking/reservation/state_transition_test.py index 548c8a989..162b18519 100644 --- a/backend/test/services/coworking/reservation/state_transition_test.py +++ b/backend/test/services/coworking/reservation/state_transition_test.py @@ -84,11 +84,12 @@ def test_state_transition_reservation_entities_by_time_expired_active( def test_state_transition_reservation_entities_by_time_active_draft( session: Session, reservation_svc: ReservationService, policy_svc: PolicyService ): + coworking_policy = policy_svc.default_policy() entities: list[ReservationEntity] = [ session.get(ReservationEntity, reservation.id) for reservation in reservation_data.draft_reservations ] - cutoff = entities[0].created_at + policy_svc.reservation_draft_timeout() + cutoff = entities[0].created_at + coworking_policy.reservation_draft_timeout collected = reservation_svc._state_transition_reservation_entities_by_time( cutoff, entities ) @@ -99,9 +100,10 @@ def test_state_transition_reservation_entities_by_time_active_draft( def test_state_transition_reservation_entities_by_time_expired_draft( session: Session, reservation_svc: ReservationService, policy_svc: PolicyService ): + coworking_policy = policy_svc.default_policy() policy_mock = create_autospec(PolicyService) policy_mock.reservation_draft_timeout.return_value = ( - policy_svc.reservation_draft_timeout() + coworking_policy.reservation_draft_timeout ) reservation_svc._policy_svc = policy_mock @@ -111,7 +113,7 @@ def test_state_transition_reservation_entities_by_time_expired_draft( ] cutoff = ( entities[0].created_at - + policy_svc.reservation_draft_timeout() + + coworking_policy.reservation_draft_timeout + timedelta(seconds=1) ) collected = reservation_svc._state_transition_reservation_entities_by_time( @@ -128,9 +130,10 @@ def test_state_transition_reservation_entities_by_time_expired_draft( def test_state_transition_reservation_entities_by_time_checkin_timeout( session: Session, reservation_svc: ReservationService, policy_svc: PolicyService ): + coworking_policy = policy_svc.default_policy() policy_mock = create_autospec(PolicyService) policy_mock.reservation_checkin_timeout.return_value = ( - policy_svc.reservation_checkin_timeout() + coworking_policy.reservation_checkin_timeout ) reservation_svc._policy_svc = policy_mock @@ -140,7 +143,7 @@ def test_state_transition_reservation_entities_by_time_checkin_timeout( ] cutoff = ( entities[0].start - + policy_svc.reservation_checkin_timeout() + + coworking_policy.reservation_checkin_timeout + timedelta(seconds=1) ) collected = reservation_svc._state_transition_reservation_entities_by_time( diff --git a/backend/test/services/fixtures.py b/backend/test/services/fixtures.py index ecdf59aca..d64ead34b 100644 --- a/backend/test/services/fixtures.py +++ b/backend/test/services/fixtures.py @@ -88,7 +88,7 @@ def article_svc(session: Session): return ArticleService( session, PermissionService(session), - PolicyService(), + PolicyService(session), OperatingHoursService(session, PermissionService(session)), ) diff --git a/frontend/.postcssrc.json b/frontend/.postcssrc.json new file mode 100644 index 000000000..9674304b0 --- /dev/null +++ b/frontend/.postcssrc.json @@ -0,0 +1,5 @@ +{ + "plugins": { + "@tailwindcss/postcss": {} + } +} \ No newline at end of file diff --git a/frontend/angular.json b/frontend/angular.json index 8d6b9945d..5737134a1 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -27,8 +27,8 @@ ], "styles": [ "node_modules/@angular/material/prebuilt-themes/magenta-violet.css", - "src/styles/styles.scss" - + "src/styles/styles.scss", + "src/styles/tailwind.css" ], "scripts": [], "browser": "src/main.ts" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2745f7dff..4eb3faab9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,11 +20,14 @@ "@angular/router": "~20.3.1", "@angular/youtube-player": "~20.2.4", "@auth0/angular-jwt": "~5.2.0", + "@tailwindcss/postcss": "^4.1.13", "dompurify": "~3.1.7", "file-saver": "~2.0.5", "marked": "~14.0.0", "openmeteo": "^1.1.4", + "postcss": "^8.5.6", "rxjs": "~7.8.1", + "tailwindcss": "^4.1.13", "tslib": "~2.6.3", "zone.js": "~0.15.1" }, @@ -51,6 +54,7 @@ "karma-coverage": "~2.2.1", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.5.3", "prettier": "~3.3.3", "prettier-eslint": "~16.3.0", "typescript": "~5.9.2" @@ -265,6 +269,18 @@ "node": ">= 14.0.0" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1490,7 +1506,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1502,7 +1517,6 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1513,7 +1527,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2692,7 +2705,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -2728,35 +2740,41 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true, + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -3370,7 +3388,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.5.tgz", "integrity": "sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -4885,6 +4902,326 @@ "dev": true, "license": "MIT" }, + "node_modules/@tailwindcss/node": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.13.tgz", + "integrity": "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.5.1", + "lightningcss": "1.30.1", + "magic-string": "^0.30.18", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.13" + } + }, + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.19", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", + "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.13.tgz", + "integrity": "sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.4", + "tar": "^7.4.3" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-arm64": "4.1.13", + "@tailwindcss/oxide-darwin-x64": "4.1.13", + "@tailwindcss/oxide-freebsd-x64": "4.1.13", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.13", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.13", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.13", + "@tailwindcss/oxide-linux-x64-musl": "4.1.13", + "@tailwindcss/oxide-wasm32-wasi": "4.1.13", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.13", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.13" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.13.tgz", + "integrity": "sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.13.tgz", + "integrity": "sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.13.tgz", + "integrity": "sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.13.tgz", + "integrity": "sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.13.tgz", + "integrity": "sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.13.tgz", + "integrity": "sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.13.tgz", + "integrity": "sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.13.tgz", + "integrity": "sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.13.tgz", + "integrity": "sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.13.tgz", + "integrity": "sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.5", + "@emnapi/runtime": "^1.4.5", + "@emnapi/wasi-threads": "^1.0.4", + "@napi-rs/wasm-runtime": "^0.2.12", + "@tybys/wasm-util": "^0.10.0", + "tslib": "^2.8.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz", + "integrity": "sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.13.tgz", + "integrity": "sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/oxide/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.13.tgz", + "integrity": "sha512-HLgx6YSFKJT7rJqh9oJs/TkBFhxuMOfUKSBEPYwV+t78POOBsdQ7crhZLzwcH3T0UyUuOzU/GK5pk5eKr3wCiQ==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.13", + "@tailwindcss/oxide": "4.1.13", + "postcss": "^8.4.41", + "tailwindcss": "4.1.13" + } + }, "node_modules/@tufjs/canonical-json": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", @@ -4913,7 +5250,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -6305,9 +6641,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz", "integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==", - "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -6539,6 +6873,19 @@ "node": ">=10.0.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/ent": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/ent/-/ent-2.2.1.tgz", @@ -7871,7 +8218,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -8501,6 +8847,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jiti": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -8946,6 +9301,234 @@ "node": ">= 0.8.0" } }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/listr2": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.1.tgz", @@ -9587,7 +10170,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -9727,7 +10309,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "dev": true, "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -9814,7 +10395,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -10717,7 +11297,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -10760,7 +11339,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -12255,7 +12833,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -12530,6 +13107,25 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/tailwindcss": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz", + "integrity": "sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", + "integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 983681735..7e842302b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,11 +24,14 @@ "@angular/router": "~20.3.1", "@angular/youtube-player": "~20.2.4", "@auth0/angular-jwt": "~5.2.0", + "@tailwindcss/postcss": "^4.1.13", "dompurify": "~3.1.7", "file-saver": "~2.0.5", "marked": "~14.0.0", "openmeteo": "^1.1.4", + "postcss": "^8.5.6", "rxjs": "~7.8.1", + "tailwindcss": "^4.1.13", "tslib": "~2.6.3", "zone.js": "~0.15.1" }, @@ -55,6 +58,7 @@ "karma-coverage": "~2.2.1", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.5.3", "prettier": "~3.3.3", "prettier-eslint": "~16.3.0", "typescript": "~5.9.2" diff --git a/frontend/src/app/admin/admin.component.html b/frontend/src/app/admin/admin.component.html index 7cd7fc828..bc84c4577 100644 --- a/frontend/src/app/admin/admin.component.html +++ b/frontend/src/app/admin/admin.component.html @@ -1,3 +1,18 @@ + + +
+ Operating Hours + +
+
+
+
diff --git a/frontend/src/app/coworking/coworking-home/coworking-home.component.ts b/frontend/src/app/coworking/coworking-home/coworking-home.component.ts index f0c04056d..4ddaf957b 100644 --- a/frontend/src/app/coworking/coworking-home/coworking-home.component.ts +++ b/frontend/src/app/coworking/coworking-home/coworking-home.component.ts @@ -13,23 +13,19 @@ import { isAuthenticated } from 'src/app/gate/gate.guard'; import { profileResolver } from 'src/app/profile/profile.resolver'; import { CoworkingService } from '../coworking.service'; import { Profile } from 'src/app/models.module'; -import { ProfileService } from 'src/app/profile/profile.service'; import { CoworkingStatus, Reservation, SeatAvailability } from '../coworking.models'; import { Subscription, timer } from 'rxjs'; -import { RoomReservationService } from '../room-reservation/room-reservation.service'; -import { ReservationService } from '../reservation/reservation.service'; -import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; @Component({ - selector: 'app-coworking-home', - templateUrl: './coworking-home.component.html', - styleUrls: ['./coworking-home.component.css'], - standalone: false + selector: 'app-coworking-home', + templateUrl: './coworking-home.component.html', + styleUrls: ['./coworking-home.component.css'], + standalone: false }) export class CoworkingPageComponent implements OnInit, OnDestroy { public status: Signal; @@ -82,11 +78,7 @@ export class CoworkingPageComponent implements OnInit, OnDestroy { public coworkingService: CoworkingService, private router: Router, private route: ActivatedRoute, - private reservationService: ReservationService, - protected snackBar: MatSnackBar, - private roomReservationService: RoomReservationService, - private profileService: ProfileService, - private dialog: MatDialog + protected snackBar: MatSnackBar ) { this.status = coworkingService.status; @@ -118,11 +110,7 @@ export class CoworkingPageComponent implements OnInit, OnDestroy { reserve(seatSelection: SeatAvailability[]) { this.coworkingService.draftReservation(seatSelection).subscribe({ error: (response) => { - this.snackBar.open( - response.error.message, - '', - { duration: 8000 } - ); + this.snackBar.open(response.error.message, '', { duration: 8000 }); }, next: (reservation) => { this.router.navigateByUrl(`/coworking/reservation/${reservation.id}`); diff --git a/frontend/src/app/coworking/coworking-routing.module.ts b/frontend/src/app/coworking/coworking-routing.module.ts index 36bb7827d..019851f18 100644 --- a/frontend/src/app/coworking/coworking-routing.module.ts +++ b/frontend/src/app/coworking/coworking-routing.module.ts @@ -2,14 +2,16 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { CoworkingPageComponent } from './coworking-home/coworking-home.component'; import { ReservationComponent } from './reservation/reservation.component'; -import { NewReservationPageComponent } from './room-reservation/new-reservation-page/new-reservation-page.component'; -import { ConfirmReservationComponent } from './room-reservation/confirm-reservation/confirm-reservation.component'; +import { NewRoomReservationComponent } from './room-reservation/room-reservation.component'; +import { OperatingHoursComponent } from './operating-hours/operating-hours.component'; +import { OperatingHoursEditorComponent } from './operating-hours/editor/operating-hours-editor.component'; const routes: Routes = [ CoworkingPageComponent.Route, ReservationComponent.Route, - NewReservationPageComponent.Route, - ConfirmReservationComponent.Route, + NewRoomReservationComponent.Route, + OperatingHoursComponent.Route, + OperatingHoursEditorComponent.Route, { path: 'ambassador', title: 'Ambassador', diff --git a/frontend/src/app/coworking/coworking.models.ts b/frontend/src/app/coworking/coworking.models.ts index be0ebfb48..e2b484bc4 100644 --- a/frontend/src/app/coworking/coworking.models.ts +++ b/frontend/src/app/coworking/coworking.models.ts @@ -112,7 +112,7 @@ export const parseCoworkingStatusJSON = ( }; export interface ReservationRequest extends TimeRange { - users: Profile[] | null; + users: Profile[] | { id: number }[] | null; seats: Seat[] | null; room: { id: string }; } @@ -180,3 +180,29 @@ export interface ReservationMapDetails { operating_hours_end: string; number_of_time_slots: number; } + +/** New room reservation models */ + +export type GetRoomAvailabilityResponse_RoomAvailability = { + state: 'AVAILABLE' | 'RESERVED' | 'YOUR_RESERVATION' | 'UNAVAILABLE'; + description?: string | undefined; +}; + +export type GetRoomAvailabilityResponse_Slot = { + start_time: string; + end_time: string; +}; + +export type GetRoomAvailabilityResponse_Room = { + room: string; + capacity: number; + minimum_reservers: number; + availability: Record; +}; + +export type GetRoomAvailabilityResponse = { + is_instructor: boolean; + slot_labels: string[]; + slots: Record; + rooms: GetRoomAvailabilityResponse_Room[]; +}; diff --git a/frontend/src/app/coworking/coworking.module.ts b/frontend/src/app/coworking/coworking.module.ts index fe82bd99e..78e7a15c3 100644 --- a/frontend/src/app/coworking/coworking.module.ts +++ b/frontend/src/app/coworking/coworking.module.ts @@ -1,5 +1,6 @@ import { AsyncPipe, CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; import { CoworkingRoutingModule } from './coworking-routing.module'; import { CoworkingPageComponent } from './coworking-home/coworking-home.component'; @@ -14,16 +15,11 @@ import { ReservationComponent } from './reservation/reservation.component'; import { MatIconModule } from '@angular/material/icon'; import { MatDividerModule } from '@angular/material/divider'; import { MatButtonModule } from '@angular/material/button'; -import { NewReservationPageComponent } from './room-reservation/new-reservation-page/new-reservation-page.component'; -import { RoomReservationWidgetComponent } from './widgets/room-reservation-table/room-reservation-table.widget'; -import { ConfirmReservationComponent } from './room-reservation/confirm-reservation/confirm-reservation.component'; -import { DateSelector } from './widgets/date-selector/date-selector.widget'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatNativeDateModule } from '@angular/material/core'; import { OperatingHoursDialog } from './widgets/operating-hours-dialog/operating-hours-dialog.widget'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatAutocompleteModule } from '@angular/material/autocomplete'; -import { ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { SharedModule } from '../shared/shared.module'; import { MatTabsModule } from '@angular/material/tabs'; @@ -31,13 +27,14 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { AmbassadorXLComponent } from './ambassador-home/ambassador-xl/ambassador-xl.component'; import { AmbassadorRoomComponent } from './ambassador-home/ambassador-room/ambassador-room.component'; import { ReservationFactsWidget } from './widgets/reservation-facts/reservation-facts.widget'; -import { DialogModule } from '@angular/cdk/dialog'; import { MatDialogModule } from '@angular/material/dialog'; +import { NewRoomReservationComponent } from './room-reservation/room-reservation.component'; +import { OperatingHoursComponent } from './operating-hours/operating-hours.component'; +import { OperatingHoursEditorComponent } from './operating-hours/editor/operating-hours-editor.component'; +import { MatTimepickerModule } from '@angular/material/timepicker'; @NgModule({ declarations: [ - NewReservationPageComponent, - RoomReservationWidgetComponent, CoworkingPageComponent, ReservationComponent, AmbassadorPageComponent, @@ -45,14 +42,15 @@ import { MatDialogModule } from '@angular/material/dialog'; AmbassadorRoomComponent, CoworkingDropInCard, CoworkingReservationCard, - ConfirmReservationComponent, - NewReservationPageComponent, - DateSelector, OperatingHoursDialog, - ReservationFactsWidget + ReservationFactsWidget, + NewRoomReservationComponent, + OperatingHoursComponent, + OperatingHoursEditorComponent ], imports: [ CommonModule, + ReactiveFormsModule, MatCardModule, MatButtonModule, MatDividerModule, @@ -77,7 +75,8 @@ import { MatDialogModule } from '@angular/material/dialog'; MatFormFieldModule, MatTooltipModule, MatTabsModule, - MatDialogModule + MatDialogModule, + MatTimepickerModule ] }) export class CoworkingModule {} diff --git a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.css b/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.css similarity index 100% rename from frontend/src/app/coworking/widgets/date-selector/date-selector.widget.css rename to frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.css diff --git a/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.html b/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.html new file mode 100644 index 000000000..6eff4818c --- /dev/null +++ b/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.html @@ -0,0 +1,59 @@ +
+ + + New Operating Hours + + + + + Date + + + + + + + + Start Time + + + + + + + + End Time + + + + + + + + + +
diff --git a/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.ts b/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.ts new file mode 100644 index 000000000..dc6a424e1 --- /dev/null +++ b/frontend/src/app/coworking/operating-hours/editor/operating-hours-editor.component.ts @@ -0,0 +1,95 @@ +import { DatePipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { permissionGuard } from 'src/app/permission.guard'; +import { TimeRange } from 'src/app/time-range'; +import { OperatingHoursService } from '../operating-hours.service'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-operating-hours-editor', + templateUrl: './operating-hours-editor.component.html', + styleUrl: './operating-hours-editor.component.css', + standalone: false +}) +export class OperatingHoursEditorComponent { + public static Route = { + path: 'operating-hours/new', + component: OperatingHoursEditorComponent, + title: 'Operating Hours', + canActivate: [ + permissionGuard( + 'coworking.operating_hours.*', + 'coworking/operating_hours' + ) + ] + }; + + public form = this.formBuilder.group({ + date: new FormControl(this.datePipe.transform(new Date(), 'yyyy-MM-dd'), [ + Validators.required + ]), + start: new FormControl( + this.datePipe.transform( + new Date().setHours(9, 0, 0, 0), + 'yyyy-MM-ddTHH:mm' + ), + [Validators.required] + ), + end: new FormControl( + this.datePipe.transform( + new Date().setHours(18, 0, 0, 0), + 'yyyy-MM-ddTHH:mm' + ), + [Validators.required] + ) + }); + + constructor( + protected formBuilder: FormBuilder, + private datePipe: DatePipe, + protected operatingHoursService: OperatingHoursService, + protected snackBar: MatSnackBar, + protected router: Router + ) {} + + onSubmit() { + const selectedDate = new Date(this.form.value.date!); + const startTime = new Date(this.form.value.start!); + const endTime = new Date(this.form.value.end!); + + // Combine the selected date with the start and end times + const startDateTime = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate(), + startTime.getHours(), + startTime.getMinutes() + ); + + const endDateTime = new Date( + selectedDate.getFullYear(), + selectedDate.getMonth(), + selectedDate.getDate(), + endTime.getHours(), + endTime.getMinutes() + ); + + const range: TimeRange = { + start: startDateTime, + end: endDateTime + }; + + this.operatingHoursService.newOperatingHours(range).subscribe({ + next: () => { + this.router.navigateByUrl(`/coworking/operating-hours`); + }, + error: (err) => { + this.snackBar.open(`${err.error.message}`, '', { + duration: 2000 + }); + } + }); + } +} diff --git a/frontend/src/app/coworking/operating-hours/operating-hours.component.css b/frontend/src/app/coworking/operating-hours/operating-hours.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/coworking/operating-hours/operating-hours.component.html b/frontend/src/app/coworking/operating-hours/operating-hours.component.html new file mode 100644 index 000000000..4ccaf42be --- /dev/null +++ b/frontend/src/app/coworking/operating-hours/operating-hours.component.html @@ -0,0 +1,23 @@ + + + Operating Hours Administration + + + + @for(hours of operatingHours; track hours.id) { +
+

{{ hours.start | date: 'EEE' }}

+

{{ hours.start | date: 'MMM dd' }}

+

{{ hours.start | date: 'h:mma' }} - {{ hours.end | date: 'h:mma' }}

+
+ +
+
+ } +
+
\ No newline at end of file diff --git a/frontend/src/app/coworking/operating-hours/operating-hours.component.ts b/frontend/src/app/coworking/operating-hours/operating-hours.component.ts new file mode 100644 index 000000000..5c1f92de8 --- /dev/null +++ b/frontend/src/app/coworking/operating-hours/operating-hours.component.ts @@ -0,0 +1,59 @@ +import { Component } from '@angular/core'; +import { permissionGuard } from 'src/app/permission.guard'; +import { OperatingHoursService } from './operating-hours.service'; +import { OperatingHours } from '../coworking.models'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-operating-hours', + templateUrl: './operating-hours.component.html', + styleUrl: './operating-hours.component.css', + standalone: false +}) +export class OperatingHoursComponent { + public static Route = { + path: 'operating-hours', + component: OperatingHoursComponent, + title: 'Operating Hours', + canActivate: [ + permissionGuard( + 'coworking.operating_hours.*', + 'coworking/operating_hours' + ) + ] + }; + + operatingHours: OperatingHours[] = []; + + constructor( + protected operatingHoursService: OperatingHoursService, + protected snackBar: MatSnackBar, + protected router: Router + ) { + this.fetchOperatingHours(); + } + + fetchOperatingHours() { + this.operatingHoursService.getOperatingHours().subscribe((result) => { + this.operatingHours = result; + }); + } + + newOperatingHours() { + this.router.navigateByUrl('/coworking/operating-hours/new'); + } + + deleteOperatingHours(id: number) { + this.operatingHoursService.deleteOperatingHours(id).subscribe({ + next: () => { + this.fetchOperatingHours(); + }, + error: (err) => { + this.snackBar.open(`${err.error.message}`, '', { + duration: 2000 + }); + } + }); + } +} diff --git a/frontend/src/app/coworking/operating-hours/operating-hours.service.ts b/frontend/src/app/coworking/operating-hours/operating-hours.service.ts new file mode 100644 index 000000000..73e72ce2b --- /dev/null +++ b/frontend/src/app/coworking/operating-hours/operating-hours.service.ts @@ -0,0 +1,35 @@ +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; +import { OperatingHours } from '../coworking.models'; +import { Observable } from 'rxjs'; +import { TimeRange } from 'src/app/time-range'; + +@Injectable({ + providedIn: 'root' +}) +export class OperatingHoursService { + protected http = inject(HttpClient); + + getOperatingHours(): Observable { + const start = new Date(); + const end = new Date(); + end.setFullYear(end.getFullYear() + 1); // 1 year in the future + + const params = { + start: start.toISOString(), + end: end.toISOString() + }; + + return this.http.get('/api/coworking/operating_hours', { + params + }); + } + + newOperatingHours(timeRange: TimeRange) { + return this.http.post(`/api/coworking/operating_hours`, timeRange); + } + + deleteOperatingHours(id: number) { + return this.http.delete(`/api/coworking/operating_hours/${id}`); + } +} diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.css b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.css deleted file mode 100644 index 4c3fd726f..000000000 --- a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.css +++ /dev/null @@ -1,34 +0,0 @@ -.card-container { - margin: 16px; -} - -.mat-mdc-card { - max-width: 640px; - margin: 0px; -} - -.mat-mdc-card-header { - margin-bottom: 16px; -} - -.mat-mdc-card-actions { - margin-top: 16px; -} - -.mat-divider { - margin: 1em 0; -} - -h3 { - font-size: 18px; - margin-bottom: 0px; -} - -h3 > label { - width: 64px; - display: inline-block; -} - -p { - margin-left: 64px; -} diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.html b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.html deleted file mode 100644 index 2b1cdd2e6..000000000 --- a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.html +++ /dev/null @@ -1,8 +0,0 @@ -
- @if(reservation) { - - - } -
diff --git a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.ts b/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.ts deleted file mode 100644 index c06314eb3..000000000 --- a/frontend/src/app/coworking/room-reservation/confirm-reservation/confirm-reservation.component.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * @author John Schachte, Aarjav Jain, Nick Wherthey - * @copyright 2023 - * @license MIT - */ - -import { Component, OnInit, OnDestroy } from '@angular/core'; -import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute, Router } from '@angular/router'; -import { timer } from 'rxjs'; -import { isAuthenticated } from 'src/app/gate/gate.guard'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { Reservation } from '../../coworking.models'; -import { RoomReservationService } from '../room-reservation.service'; - -@Component({ - selector: 'app-confirm-reservation', - templateUrl: './confirm-reservation.component.html', - styleUrls: ['./confirm-reservation.component.css'], - standalone: false -}) -export class ConfirmReservationComponent implements OnInit, OnDestroy { - public static Route = { - path: 'confirm-reservation/:id', - title: 'Confirm Reservation', - component: ConfirmReservationComponent, - canActivate: [isAuthenticated], - resolve: { profile: profileResolver } - }; - - reservation: Reservation | null = null; // Declaration of the reservation property - isConfirmed: boolean = false; // flag to see if reservation was confirmed - - public id: number; - - constructor( - private roomReservationService: RoomReservationService, - protected snackBar: MatSnackBar, - private router: Router, - public route: ActivatedRoute - ) { - this.id = parseInt(this.route.snapshot.params['id']); - } - - ngOnDestroy(): void { - if (this.isConfirmed) return; - this.roomReservationService.cancel(this.reservation!).subscribe(); - } - - /** - * A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. - * - * Use this hook to initialize the directive or component. This is the right place to fetch data from a server, - * set up any local state, or perform operations that need to be executed only once when the component is instantiated. - * - * @returns {void} - This method does not return a value. - */ - ngOnInit() { - this.roomReservationService.getReservationObservable(this.id).subscribe({ - next: (response) => (this.reservation = response), // Assume only one draft per user - error: (error) => { - this.snackBar.open('Error while fetching draft reservation.', '', { - duration: 8000 - }); - timer(3000).subscribe(() => - this.router.navigateByUrl('/coworking/new-reservation') - ); - console.error(error.message); - } - }); - } - - setConfirmation(isConfirmed: boolean) { - this.isConfirmed = isConfirmed; - } -} diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.css b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.css deleted file mode 100644 index a84731fc1..000000000 --- a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.css +++ /dev/null @@ -1,78 +0,0 @@ -::ng-deep .mat-mdc-card-outlined { - max-width: 100% !important; - margin-right: 32px !important; -} - -.mat-mdc-card-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 12px; -} - -.mat-mdc-card-title { - padding-left: 10px; -} - -.mat-mdc-card-subtitle { - padding-left: 10px; -} - - -/* Add any additional styling for the room-reservation-table if needed */ - -.date-selector { - padding: 10px; /* Adjust the value as needed */ -} - -.upcoming-reservations-container { - display: flex; - flex-wrap: wrap; - gap: 20px; /* Adjust the gap as needed */ -} - -.coworking-reservation-card { - width: calc(50% - 10px); /* Adjust the width and margin as needed */ - margin-bottom: 20px; - box-sizing: border-box; /* Include padding and border in the total width */ -} - -.heading { - padding-top: 30px; - padding-left: 10px; -} - -.legend-container { - display: flex; - flex-wrap: wrap; - margin-top: 20px; /* Adjust the margin as needed */ -} - -.legend-item { - display: flex; - align-items: center; - margin-left: 10px; /* Adjust the margin between legend items */ - margin-top: 5px; -} - -.legend-color { - width: 20px; - height: 20px; - margin-right: 5px; - border-radius: 6px; -} - -.legend-text { - font-size: 14px; -} - -.reservation-limit { - padding-left: 10px; -} - -@media only screen and (max-width: 900px) { - .mat-mdc-card-header { - flex-direction: column; - align-items: baseline; - } -} \ No newline at end of file diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.html b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.html deleted file mode 100644 index 7ebda32a4..000000000 --- a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.html +++ /dev/null @@ -1,39 +0,0 @@ - - - Reserve a Room - Total Hours Remaining: - {{ numHoursStudyRoomReservations$ | async }} -
- -
-
- - - - -
-
-
- Available -
-
-
- Reserved -
-
-
- Your Reservations -
-
-
- Selected -
-
-
- Unavailable -
-
-
-
diff --git a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.ts b/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.ts deleted file mode 100644 index 0ff9b5b17..000000000 --- a/frontend/src/app/coworking/room-reservation/new-reservation-page/new-reservation-page.component.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @author John Schachte, Aarjav Jain, Nick Wherthey - * @copyright 2023 - * @license MIT - */ - -import { Component, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; -import { Reservation } from 'src/app/coworking/coworking.models'; -import { isAuthenticated } from 'src/app/gate/gate.guard'; -import { profileResolver } from 'src/app/profile/profile.resolver'; -import { catchError, Observable, of } from 'rxjs'; -import { RoomReservationService } from '../room-reservation.service'; -import { MatSnackBar } from '@angular/material/snack-bar'; - -@Component({ - selector: 'app-new-reservation-page', - templateUrl: './new-reservation-page.component.html', - styleUrls: ['./new-reservation-page.component.css'], - standalone: false -}) -export class NewReservationPageComponent implements OnInit { - public static Route = { - path: 'new-reservation', - title: 'New Reservation', - component: NewReservationPageComponent, - canActivate: [isAuthenticated], - resolve: { profile: profileResolver } - }; - - public numHoursStudyRoomReservations$!: Observable; - - constructor( - private router: Router, - private roomReservationService: RoomReservationService, - protected snackBar: MatSnackBar - ) {} - - /** - * A lifecycle hook that is called after Angular has initialized all data-bound properties of a directive. - * - * Use this hook to initialize the directive or component. This is the right place to fetch data from a server, - * set up any local state, or perform operations that need to be executed only once when the component is instantiated. - * - * @returns {void} - This method does not return a value. - */ - - ngOnInit() { - this.getNumHoursStudyRoomReservations(); - } - - navigateToNewReservation() { - this.router.navigateByUrl('/coworking/new-reservation'); - } - - getNumHoursStudyRoomReservations() { - this.numHoursStudyRoomReservations$ = - this.roomReservationService.getNumHoursStudyRoomReservations(); - } -} diff --git a/frontend/src/app/coworking/room-reservation/reservation-table.service.ts b/frontend/src/app/coworking/room-reservation/reservation-table.service.ts deleted file mode 100644 index 74fb2994b..000000000 --- a/frontend/src/app/coworking/room-reservation/reservation-table.service.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** - * @author John Schachte, Aarjav Jain, Nick Wherthey - * @copyright 2023 - * @license MIT - */ - -import { HttpClient, HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { BehaviorSubject, Observable, Subscription } from 'rxjs'; -import { - Reservation, - ReservationMapDetails, - ReservationRequest, - TableCell, - TablePropertyMap -} from '../coworking.models'; -import { ProfileService } from '../../profile/profile.service'; -import { Profile } from '../../models.module'; -import { RoomReservationWidgetComponent } from '../widgets/room-reservation-table/room-reservation-table.widget'; - -@Injectable({ - providedIn: 'root' -}) -export class ReservationTableService { - private selectedDateSubject = new BehaviorSubject(''); - selectedDate$ = this.selectedDateSubject.asObservable(); - private profile: Profile | undefined; - private profileSubscription!: Subscription; - - private EndingOperationalHour: number = 18; - - static readonly MAX_RESERVATION_CELL_LENGTH: number = 4; // rule for how long a reservation can be consecutively - - static readonly CellEnum = { - AVAILABLE: 0, - BOOKED: 1, - RESERVING: 2, - UNAVAILABLE: 3, - SUBJECT_RESERVATION: 4 - } as const; - - //Add table cell states here - static readonly CellPropertyMap: TablePropertyMap = { - [ReservationTableService.CellEnum.AVAILABLE]: { - backgroundColor: '#03691e', - isDisabled: false - }, - [ReservationTableService.CellEnum.BOOKED]: { - backgroundColor: '#B3261E', - isDisabled: true - }, - [ReservationTableService.CellEnum.RESERVING]: { - backgroundColor: 'orange', - isDisabled: false - }, - [ReservationTableService.CellEnum.UNAVAILABLE]: { - backgroundColor: '#4d4d4d', - isDisabled: true - }, - [ReservationTableService.CellEnum.SUBJECT_RESERVATION]: { - backgroundColor: '#3479be', - isDisabled: true - } - }; - - constructor( - private http: HttpClient, - protected profileSvc: ProfileService - ) { - this.profileSubscription = this.profileSvc.profile$.subscribe( - (profile) => (this.profile = profile) - ); - } - - setSelectedDate(date: string) { - this.selectedDateSubject.next(date); - } - - //TODO Change route from ISO String to date object - getReservationsForRoomsByDate(date: Date): Observable { - let params = new HttpParams().set('date', date.toISOString()); - return this.http.get( - `/api/coworking/room-reservation/`, - { params } - ); - } - - draftReservation( - reservationsMap: Record, - operationStart: Date - ): Observable { - const selectedRoom: { room: string; availability: number[] } | null = - this._findSelectedRoom(reservationsMap); - - if (!selectedRoom) throw new Error('No room selected'); - const reservationRequest: ReservationRequest = this._makeReservationRequest( - selectedRoom!, - operationStart - ); - return this.makeDraftReservation(reservationRequest); - } - - //TODO Draft Reservation method - makeDraftReservation( - reservationRequest: ReservationRequest - ): Observable { - return this.http.post( - `/api/coworking/reservation`, - reservationRequest - ); - } - - /** - * Deselects a cell in the reservations map and updates selected cells. - * - * @param {string} key - The key representing the room in the reservations map. - * @param {number} index - The index representing the time slot in the reservations map. - * @returns {void} The method does not return a value. - * @public This method is intended for internal use. - */ - public deselectCell( - key: string, - index: number, - tableWidget: RoomReservationWidgetComponent - ): void { - tableWidget.setSlotAvailable(key, index); - - tableWidget.selectedCells = tableWidget.selectedCells.filter( - (cell) => !(cell.key === key && cell.index === index) - ); - - const isAllAdjacent = this._areAllAdjacent(tableWidget.selectedCells); - if (!isAllAdjacent) { - tableWidget.selectedCells = this._updateAdjacentCells(index, tableWidget); - } - } - - /** - * Selects a cell in the reservations map and updates selected cells. - * - * @param {string} key - The key representing the room in the reservations map. - * @param {number} index - The index representing the time slot in the reservations map. - * @returns {void} The method does not return a value. - * @public This method is intended for internal use. - */ - - public selectCell( - key: string, - index: number, - tableWidget: RoomReservationWidgetComponent - ): void { - const isAdjacentToAny = tableWidget.selectedCells.some( - (cell: TableCell) => { - return Math.abs(index - cell.index) <= 1 && key === cell.key; - } - ); - - if ( - isAdjacentToAny && - tableWidget.selectedCells.length < - ReservationTableService.MAX_RESERVATION_CELL_LENGTH - ) { - // If adjacent and within the maximum reservation length, select the cell - tableWidget.setSlotReserving(key, index); - - tableWidget.selectedCells.push({ key, index }); - } else if ( - tableWidget.selectedCells.length >= - ReservationTableService.MAX_RESERVATION_CELL_LENGTH - ) { - // If the maximum reservation length is exceeded, deselect all cells - this._setAllAvailable(tableWidget); - tableWidget.selectedCells = [{ key, index }]; // resetting selected cells to lone cell - tableWidget.setSlotReserving(key, index); - } else { - // If not adjacent to any selected cells, deselect all and select the new cell - this._setAllAvailable(tableWidget); - tableWidget.selectedCells = [{ key, index }]; // resetting selected cells to lone cell - tableWidget.setSlotReserving(key, index); - } - } - - _findSelectedRoom( - reservationsMap: Record - ): { room: string; availability: number[] } | null { - //- Finding the room with the selected cells (assuming only 1 row) - const result = Object.entries(reservationsMap).find( - ([id, availability]) => { - return availability.includes( - ReservationTableService.CellEnum.RESERVING - ); - } - ); - return result ? { room: result[0], availability: result[1] } : null; - } - - _makeReservationRequest( - selectedRoom: { room: string; availability: number[] }, - operationStart: Date - ): ReservationRequest { - const minIndex = selectedRoom?.availability.indexOf( - ReservationTableService.CellEnum.RESERVING - ); - const maxIndex = selectedRoom?.availability.lastIndexOf( - ReservationTableService.CellEnum.RESERVING - ); - const thirtyMinutes = 30 * 60 * 1000; - const startDateTime = new Date( - operationStart.getTime() + thirtyMinutes * minIndex - ); - - const endDateTime = new Date( - operationStart.getTime() + thirtyMinutes * (maxIndex + 1) - ); - - return { - users: [this.profile!], - seats: [], - room: { id: selectedRoom!.room }, - start: startDateTime, - end: endDateTime - }; - } - - /** - * Makes all selected cells Available. - * @param tableWidget RoomReservationWidgetComponent - * @returns void - * @private This method is intended for internal use. - * - */ - private _setAllAvailable(tableWidget: RoomReservationWidgetComponent): void { - tableWidget.selectedCells.forEach((cell: TableCell) => { - tableWidget.setSlotAvailable(cell.key, cell.index); - }); - } - - /** - * Checks if all currently selected cells are adjacent to each other. - * - * @returns {boolean} True if all selected cells are adjacent, false otherwise. - * @private This method is intended for internal use. - */ - - private _areAllAdjacent(selectedCells: TableCell[]): boolean { - return selectedCells.every((cell: TableCell, i) => { - if (i < selectedCells.length - 1) { - const nextCell = selectedCells[i + 1]; - return Math.abs(cell.index - nextCell.index) <= 1; // Check if the next cell is adjacent - } - return true; // Always return true for the last element - }); - } - - /** - * Updates adjacent cells based on the index of the selected cell. - * - * @param {number} index - The index representing the time slot in the reservations map. - * @returns {void} The method does not return a value. - * @private This method is intended for internal use. - */ - - private _updateAdjacentCells( - index: number, - tableWidget: RoomReservationWidgetComponent - ): TableCell[] { - // count if there are more cells on the left or on the right - const leftFrom = this._countIfOnLeft(index, tableWidget.selectedCells); - const rightFrom = tableWidget.selectedCells.length - leftFrom; // right and left counts are disjoint - return this._filterCellsBasedOnIndex( - tableWidget, - index, - leftFrom < rightFrom - ); - } - - /** - * Filters selected cells based on their index relative to a given index. - * - * @param tableWidget The RoomReservationWidgetComponent instance. - * @param index The index to compare against. - * @param filterBefore If true, filters out cells before the index; otherwise, filters out cells after the index. - */ - private _filterCellsBasedOnIndex( - tableWidget: RoomReservationWidgetComponent, - index: number, - filterBefore: boolean - ): TableCell[] { - return ( - tableWidget.selectedCells.filter((cell) => { - if (filterBefore && cell.index < index) { - tableWidget.setSlotAvailable(cell.key, cell.index); - return false; - } else if (!filterBefore && cell.index > index) { - tableWidget.reservationsMap[cell.key][cell.index] = - ReservationTableService.CellEnum.AVAILABLE; - return false; - } - return true; - }) ?? [] - ); - } - - /** - * Counts the number of cells on the left of the selected cell. - * @param index number - * @param selectedCells TableCell[] - * @returns number - * @private This method is intended for internal use. - */ - - private _countIfOnLeft(index: number, selectedCells: TableCell[]): number { - return selectedCells.reduce( - (count, cell) => (cell.index < index ? (count += 1) : count), - 0 - ); - } - - setMaxDate(): Date { - let result = new Date(); - result.setDate(result.getDate() + 7); - return result; - } - - setMinDate(): Date { - let result = new Date(); - if (result.getHours() >= this.EndingOperationalHour) { - result.setDate(result.getDate() + 1); - } - return result; - } - - /** - * Formats a date object into a string of the format 'HH:MMAM/PM'. - * - * @private - * @param {Date} date - The date object to be formatted. - * @returns {string} The formatted time string in 'HH:MMAM/PM' format. - */ - private formatAMPM(date: Date): string { - let hours = date.getHours(); - let minutes = date.getMinutes(); - const ampm = hours >= 12 ? 'PM' : 'AM'; - hours = hours % 12; - hours = hours ? hours : 12; // the hour '0' should be '12' - const minutesStr = minutes < 10 ? '0' + minutes : minutes.toString(); - return `${hours}:${minutesStr}${ampm}`; - } - - /** - * Generates time slots between two dates in increments of thirty minutes, formatted as 'HH:MMA/PM
to
HH:MMPM'. - * - * @private - * @param {Date} start - The start date and time for generating time slots. - * @param {Date} end - The end date and time for the time slots. - * @param {number} slots - The number of slots to generate. - * @returns {string[]} An array of strings representing the time slots in 'HH:MMA/PM
to
HH:MMPM' format. - */ - generateTimeSlots(start: Date, end: Date, slots: number): string[] { - const timeSlots = []; - const ThirtyMinutes = 30 * 60000; // Thirty minutes in milliseconds - while (start < end) { - let thirtyMinutesLater = new Date(start.getTime() + ThirtyMinutes); - timeSlots.push( - `${this.formatAMPM(start)}
to
${this.formatAMPM( - thirtyMinutesLater - )}` - ); - start = thirtyMinutesLater; - } - return timeSlots; - } -} diff --git a/frontend/src/app/coworking/room-reservation/room-reservation.component.css b/frontend/src/app/coworking/room-reservation/room-reservation.component.css new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/src/app/coworking/room-reservation/room-reservation.component.html b/frontend/src/app/coworking/room-reservation/room-reservation.component.html new file mode 100644 index 000000000..ce1478711 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/room-reservation.component.html @@ -0,0 +1,121 @@ + + + Reserve a Room +
+ + + + + + +
+
+ + @if(availability) { +
+ + + +
+ @for(slot of availability.slot_labels; track slot; let onTheHour = $even) { + @if(onTheHour) { +
+ {{ slot }} +
+ } + } +
+ + @for(roomAvailability of availability.rooms; track roomAvailability.room) { +
+ +
+ {{ roomAvailability.room }} +
+ {{ roomAvailability.capacity }} + chair_alt +
+
+ + @for(slot of availability.slot_labels; track slot) { + @let slotAvailability = roomAvailability.availability[slot]; + @switch (slotAvailability.state) { + @case('AVAILABLE') { + + @if(isSlotSelected(roomAvailability, slot)) { +
+ } @else { +
+ } + + } + @case('YOUR_RESERVATION') { +
+ } + @case('RESERVED') { + +
+ } + @case('UNAVAILABLE') { +
+ } + } + + } +
+ } +
+ } @else { +

You cannot reserve rooms for the selected date at this time.

+ } +
+ @let room = selectedRoom(); + @let selectedTimeslotRange = selectedSlotTimeRange(); + @if(selectedSlots.length > 0 && !!room && !!selectedTimeslotRange) { + +
+
+

Selected: {{ room.room }} from {{ selectedTimeslotRange.start | date:'h:mma' }} to {{ selectedTimeslotRange.end | date:'h:mma' }}

+

Reserving For:

+ +
+
+ @let remainingNeededUsers = room.minimum_reservers - selectedUsers.length; + + +
+
+
+ } +
\ No newline at end of file diff --git a/frontend/src/app/coworking/room-reservation/room-reservation.component.ts b/frontend/src/app/coworking/room-reservation/room-reservation.component.ts new file mode 100644 index 000000000..0f792a392 --- /dev/null +++ b/frontend/src/app/coworking/room-reservation/room-reservation.component.ts @@ -0,0 +1,248 @@ +import { Component, signal } from '@angular/core'; +import { isAuthenticated } from 'src/app/gate/gate.guard'; +import { profileResolver } from 'src/app/profile/profile.resolver'; +import { NewRoomReservationService } from './room-reservation.service'; +import { + GetRoomAvailabilityResponse, + GetRoomAvailabilityResponse_Room +} from '../coworking.models'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Profile } from 'src/app/models.module'; +import { PublicProfile } from 'src/app/profile/profile.service'; +import { TimeRange } from 'src/app/time-range'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { FormControl } from '@angular/forms'; + +type SlotSelection = { room: string; slot: string }; + +@Component({ + selector: 'app-room-reservation', + templateUrl: './room-reservation.component.html', + styleUrl: './room-reservation.component.css', + standalone: false +}) +export class NewRoomReservationComponent { + public static Route = { + path: 'new-reservation', + title: 'New Reservation', + component: NewRoomReservationComponent, + canActivate: [isAuthenticated], + resolve: { profile: profileResolver } + }; + + profile: PublicProfile; + + availability: GetRoomAvailabilityResponse | undefined = undefined; + + public selectedSlots: SlotSelection[] = []; + public selectedUsers: PublicProfile[] = []; + + dateControl = new FormControl(new Date()); + + constructor( + protected route: ActivatedRoute, + protected router: Router, + protected snackBar: MatSnackBar, + private roomReservationService: NewRoomReservationService + ) { + /** Initialize data from resolvers. */ + const data = this.route.snapshot.data as { + profile: Profile; + }; + this.profile = data.profile as PublicProfile; + this.selectedUsers = [this.profile]; + + this.dateControl.valueChanges.subscribe((date) => { + if (date) { + // Refresh availability when date changes + this.roomReservationService + .getAvailability(date) + .subscribe((result) => { + this.availability = + result.slot_labels.length > 0 ? result : undefined; + this.selectedSlots = []; + this.selectedUsers = [this.profile]; + }); + } + }); + + // Initial load + this.roomReservationService.getAvailability().subscribe((result) => { + this.availability = result; + }); + } + + selectSlot(room: GetRoomAvailabilityResponse_Room, slot: string) { + // Validation + if (!this.availability) { + return; + } + // First, make sure that the slot is selectable. + if (room.availability[slot].state !== 'AVAILABLE') { + return; + } + + // If there are no existing selections, select the slot. + if (this.selectedSlots.length === 0) { + this.selectedSlots.push({ room: room.room, slot }); + return; + } + + // If existing slots are for a different room, clear the selection and select. + if (this.selectedSlots[0].room !== room.room) { + this.selectedSlots = [{ room: room.room, slot }]; + this.selectedUsers = this.selectedUsers.slice(0, room.capacity); + return; + } + + // For later functionality, get the indexes of the selected slots within the + // list of slot labels. + const selectedSlotIndexes = Array.from(this.selectedSlots).map( + (selectedSlot) => { + return this.availability!.slot_labels.indexOf(selectedSlot.slot); + } + ); + const earliestSlotIndex = Math.min(...selectedSlotIndexes); + const latestSlotIndex = Math.max(...selectedSlotIndexes); + + const clickedSlotIndex = this.availability!.slot_labels.indexOf(slot); + + // If the clicked slot is already selected: + // - If the slot is at the start or end, remove just the slot + // - Otherwise, start over selection + if ( + this.selectedSlots.find((v) => v.room === room.room && v.slot === slot) + ) { + if ( + clickedSlotIndex === earliestSlotIndex || + clickedSlotIndex === latestSlotIndex + ) { + this.selectedSlots = this.selectedSlots.filter( + (v) => v.room !== room.room || v.slot !== slot + ); + return; + } else { + this.selectedSlots = [{ room: room.room, slot }]; + return; + } + } + + // Otherwise, if a slot is before the earliest selection, we start over at + // the new selection + if (clickedSlotIndex < earliestSlotIndex) { + this.selectedSlots = [{ room: room.room, slot }]; + return; + } + + // Otherwise, the final case is that the clicked slot is some time after the + // currently selected range. We want to either: + // 1. Restart the range if there is an interruption of available slots for the room between + // the start and the end of the range, or: + // 2. Fill in the range up to the selected date. + let isInterruption: boolean = false; + let currentIndex = latestSlotIndex + 1; + let slotsToAdd: SlotSelection[] = []; + while (currentIndex <= clickedSlotIndex) { + if ( + room.availability[this.availability.slot_labels[currentIndex]].state !== + 'AVAILABLE' + ) { + isInterruption = true; + } + slotsToAdd.push({ + room: room.room, + slot: this.availability.slot_labels[currentIndex] + }); + currentIndex += 1; + } + + if (isInterruption) { + this.selectedSlots = [{ room: room.room, slot }]; + return; + } else { + slotsToAdd.forEach((slot) => this.selectedSlots.push(slot)); + return; + } + } + + selectedRoom(): GetRoomAvailabilityResponse_Room | null { + if (!this.availability || this.selectedSlots.length === 0) return null; + const room = this.availability!.rooms.find( + (v) => v.room === this.selectedSlots[0].room + ); + return room ? room : null; + } + + isSlotSelected(room: GetRoomAvailabilityResponse_Room, slot: string) { + return !!this.selectedSlots.find( + (v) => v.room === room.room && v.slot === slot + ); + } + + clearSelections() { + this.selectedSlots = []; + } + + canDraftReservation() { + if (this.selectedSlots.length === 0) return false; + const room = this.selectedRoom(); + if (!room) return false; + const numSelectedUsers = this.selectedUsers.length; + return ( + this.availability!.is_instructor || + (numSelectedUsers >= room.minimum_reservers && + numSelectedUsers <= room.capacity) + ); + } + + selectedSlotTimeRange(): TimeRange | null { + if (!this.availability || this.selectedSlots.length === 0) return null; + const selectedSlotIndexes = Array.from(this.selectedSlots).map( + (selectedSlot) => { + return this.availability!.slot_labels.indexOf(selectedSlot.slot); + } + ); + const earliestSlotIndex = Math.min(...selectedSlotIndexes); + const latestSlotIndex = Math.max(...selectedSlotIndexes); + const earliestSlot = this.availability.slot_labels[earliestSlotIndex]; + const latestSlot = this.availability.slot_labels[latestSlotIndex]; + return { + start: new Date(this.availability!.slots[earliestSlot].start_time), + end: new Date(this.availability!.slots[latestSlot].end_time) + }; + } + + onUsersChanged(users: PublicProfile[]) { + this.selectedUsers = users; + } + + draftReservation() { + const timeRange = this.selectedSlotTimeRange(); + if (this.canDraftReservation() && timeRange) { + this.roomReservationService + .draftRoomReservation({ + users: this.selectedUsers.map((publicProfile) => { + return { + id: publicProfile.id + }; + }), + seats: [], + room: { id: this.selectedRoom()!.room }, + start: timeRange.start, + end: timeRange.end + }) + .subscribe({ + next: (draftReservation) => { + this.router.navigateByUrl( + `/coworking/reservation/${draftReservation.id}` + ); + }, + error: (err) => { + this.snackBar.open(`${err.error.message}`, '', { + duration: 2000 + }); + } + }); + } + } +} diff --git a/frontend/src/app/coworking/room-reservation/room-reservation.service.ts b/frontend/src/app/coworking/room-reservation/room-reservation.service.ts index 9484d260a..d90bafba3 100644 --- a/frontend/src/app/coworking/room-reservation/room-reservation.service.ts +++ b/frontend/src/app/coworking/room-reservation/room-reservation.service.ts @@ -1,31 +1,30 @@ -/** - * The Room Reservation Service abstracts HTTP requests to the backend - * from the components. - * - * @author Aarjav Jain, John Schachte, Nick Wherthey, Yuvraj Jain - * @copyright 2023 - * @license MIT - */ - -import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { map, Observable } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { inject, Injectable } from '@angular/core'; import { - parseReservationJSON, + GetRoomAvailabilityResponse, Reservation, - ReservationJSON + ReservationRequest } from '../coworking.models'; -import { ReservationService } from '../reservation/reservation.service'; +import { Observable } from 'rxjs'; @Injectable({ providedIn: 'root' }) -export class RoomReservationService extends ReservationService { - constructor(http: HttpClient) { - super(http); +export class NewRoomReservationService { + protected http = inject(HttpClient); + + getAvailability(date?: Date): Observable { + const params: { [key: string]: string } = {}; + if (date) { + params['date'] = date.toISOString(); + } + return this.http.get( + '/api/coworking/rooms/availability', + { params } + ); } - getNumHoursStudyRoomReservations(): Observable { - return this.http.get('/api/coworking/user-reservations/'); + draftRoomReservation(request: ReservationRequest) { + return this.http.post(`/api/coworking/reservation`, request); } } diff --git a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts index 563a46fb8..75893a4a2 100644 --- a/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts +++ b/frontend/src/app/coworking/widgets/coworking-reservation-card/coworking-reservation-card.ts @@ -8,15 +8,15 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { Reservation } from 'src/app/coworking/coworking.models'; import { Observable, map, timer } from 'rxjs'; import { Router } from '@angular/router'; -import { RoomReservationService } from '../../room-reservation/room-reservation.service'; import { MatSnackBar } from '@angular/material/snack-bar'; import { CoworkingService } from '../../coworking.service'; +import { ReservationService } from '../../reservation/reservation.service'; @Component({ - selector: 'coworking-reservation-card', - templateUrl: './coworking-reservation-card.html', - styleUrls: ['./coworking-reservation-card.css'], - standalone: false + selector: 'coworking-reservation-card', + templateUrl: './coworking-reservation-card.html', + styleUrls: ['./coworking-reservation-card.css'], + standalone: false }) export class CoworkingReservationCard implements OnInit { @Input() reservation!: Reservation; @@ -31,7 +31,7 @@ export class CoworkingReservationCard implements OnInit { constructor( public router: Router, - public roomReservationService: RoomReservationService, + public reservationService: ReservationService, protected snackBar: MatSnackBar, public coworkingService: CoworkingService ) { @@ -61,7 +61,7 @@ export class CoworkingReservationCard implements OnInit { } cancel() { - this.roomReservationService.cancel(this.reservation).subscribe({ + this.reservationService.cancel(this.reservation).subscribe({ next: () => { this.refreshCoworkingHome(); }, @@ -78,7 +78,7 @@ export class CoworkingReservationCard implements OnInit { confirm() { this.isConfirmed.emit(true); - this.roomReservationService.confirm(this.reservation).subscribe({ + this.reservationService.confirm(this.reservation).subscribe({ next: () => { this.refreshCoworkingHome(); // this.router.navigateByUrl('/coworking'); @@ -95,7 +95,7 @@ export class CoworkingReservationCard implements OnInit { } checkout() { - this.roomReservationService.checkout(this.reservation).subscribe({ + this.reservationService.checkout(this.reservation).subscribe({ next: () => this.refreshCoworkingHome(), error: (error: Error) => { this.snackBar.open( @@ -109,7 +109,7 @@ export class CoworkingReservationCard implements OnInit { } checkin(): void { - this.roomReservationService.checkin(this.reservation).subscribe({ + this.reservationService.checkin(this.reservation).subscribe({ next: () => { this.refreshCoworkingHome(); }, diff --git a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.html b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.html deleted file mode 100644 index 42486efd6..000000000 --- a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.ts b/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.ts deleted file mode 100644 index a738227df..000000000 --- a/frontend/src/app/coworking/widgets/date-selector/date-selector.widget.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * The date selector widget that abstracts date selection. - * - * @author Aarjav Jain, John Schachte - * @copyright 2023 - * @license MIT - */ - -import { Component, EventEmitter, Output } from '@angular/core'; -import { MatDatepickerInputEvent } from '@angular/material/datepicker'; -import { ReservationTableService } from '../../room-reservation/reservation-table.service'; - -/** - * @title Date Selector - */ -@Component({ - selector: 'date-selector', - templateUrl: './date-selector.widget.html', - styleUrls: ['./date-selector.widget.css'], - standalone: false -}) -export class DateSelector { - @Output() dateSelected = new EventEmitter(); - minDate: Date; - maxDate: Date; - - constructor(private reservationTableService: ReservationTableService) { - this.minDate = this.reservationTableService.setMinDate(); - this.maxDate = this.reservationTableService.setMaxDate(); - } - - onDateChange(event: MatDatepickerInputEvent) { - const selectedDate: string = this.formatDate(event.value!); - this.reservationTableService.setSelectedDate(selectedDate); - } - - private formatDate(date: Date): string { - // Format the date as needed, you might want to use a library like 'date-fns' or 'moment' - // For simplicity, this example uses the default 'toLocaleDateString' method - return date.toLocaleDateString(); // Adjust this based on your actual formatting requirements - } -} diff --git a/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.html b/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.html index ef491f23b..e83a26599 100644 --- a/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.html +++ b/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.html @@ -25,7 +25,6 @@

- } @@ -54,4 +53,14 @@ } + + + @if (reservation.users.length > 0) { +
+ person +
+ +
+
+ } diff --git a/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.ts b/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.ts index 2e359b1e8..824703b3d 100644 --- a/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.ts +++ b/frontend/src/app/coworking/widgets/reservation-facts/reservation-facts.widget.ts @@ -1,11 +1,12 @@ import { Component, Input } from '@angular/core'; import { Reservation } from '../../coworking.models'; +import { PublicProfile } from 'src/app/profile/profile.service'; @Component({ - selector: 'coworking-reservation-facts', - templateUrl: './reservation-facts.widget.html', - styleUrl: './reservation-facts.widget.css', - standalone: false + selector: 'coworking-reservation-facts', + templateUrl: './reservation-facts.widget.html', + styleUrl: './reservation-facts.widget.css', + standalone: false }) export class ReservationFactsWidget { @Input() reservation!: Reservation; @@ -18,4 +19,8 @@ export class ReservationFactsWidget { ) ); } + + usersList() { + return this.reservation.users.map((profile) => profile as PublicProfile); + } } diff --git a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.css b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.css deleted file mode 100644 index cf196c846..000000000 --- a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.css +++ /dev/null @@ -1,51 +0,0 @@ -/* room-reservation-table.widget.css */ - - -.table-container { - overflow-x: auto; - max-width: 100%; - -} -table { - border-collapse: collapse; - width: 99%; - margin-left: 10px; - white-space: nowrap; /* Prevent line breaks for child elements */ -} - -th, -td { - border: 1px solid #ddd; - padding: 10px; - text-align: center; -} - -th { - height: 40px; /* Set the height for header rows */ - width: 30px; -} - -td { - height: 40px; /* Set the height for data rows */ - width: 30px; -} - -.time-slot { - font-size: 12px; /* Set the font size for time slot headings */ -} - -.button { - margin-top: 20px; /* Add top margin for spacing */ - font-size: 16px; /* Increase font size */ - padding: 10px 20px; /* Add padding */ - margin-left: 10px; -} - -.divider { - margin-bottom: 1em; -} - -.room-details { - font-size: smaller; - font-weight: lighter; -} diff --git a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.html b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.html deleted file mode 100644 index 8db5774c7..000000000 --- a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.html +++ /dev/null @@ -1,36 +0,0 @@ -
-
- - - - @for(timeSlot of timeSlots; track timeSlot) { - - } - - @for (record of reservationsMap | keyvalue; track record.key) { - - - - - } -
{{ record.key }} -
{{"# seats: "}}{{capacityMap[record.key]}} -
{{roomTypeMap[record.key]}}
-
-
-
-
- -
diff --git a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.ts b/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.ts deleted file mode 100644 index 2dac53386..000000000 --- a/frontend/src/app/coworking/widgets/room-reservation-table/room-reservation-table.widget.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * @author John Schachte, Aarjav Jain, Nick Wherthey - * @copyright 2023 - * @license MIT - */ - -import { Component } from '@angular/core'; -import { ReservationTableService } from '../../room-reservation/reservation-table.service'; -import { Subscription } from 'rxjs'; -import { Router } from '@angular/router'; -import { Reservation, TableCell } from 'src/app/coworking/coworking.models'; -import { RoomReservationService } from '../../room-reservation/room-reservation.service'; -import { MatSnackBar } from '@angular/material/snack-bar'; - -@Component({ - selector: 'room-reservation-table', - templateUrl: './room-reservation-table.widget.html', - styleUrls: ['./room-reservation-table.widget.css'], - standalone: false -}) -export class RoomReservationWidgetComponent { - timeSlots: string[] = []; - - //- Reservations Map - reservationsMap: Record = {}; - - //- Capcity Map - capacityMap: Record = {}; - - //- Room Type Map - roomTypeMap: Record = {}; - - //- Select Button enabled - selectButton: boolean = false; - - operationStart: Date = new Date(); - - //- Selected Date - selectedDate: string = ''; - // private subscription: Subscription; - Object: any; - subscription: Subscription; - cellPropertyMap = ReservationTableService.CellPropertyMap; - - snackBarOptions: Object = { - duration: 8000 - }; - - constructor( - protected reservationTableService: ReservationTableService, - private router: Router, - private roomReservationService: RoomReservationService, - protected snackBar: MatSnackBar - ) { - this.reservationTableService.setSelectedDate( - this.reservationTableService.setMinDate().toDateString() - ); - this.subscription = this.reservationTableService.selectedDate$.subscribe( - (selectedDate: string) => { - this.selectedDate = selectedDate; - this.getReservationsByDate(new Date(selectedDate)); - } - ); - } - - getReservationsByDate(date: Date) { - this.reservationTableService.getReservationsForRoomsByDate(date).subscribe( - (result) => { - this.reservationsMap = result.reserved_date_map; - this.capacityMap = result.capacity_map; - this.roomTypeMap = result.room_type_map; - let end = new Date(result.operating_hours_end); - this.operationStart = new Date(result.operating_hours_start); - let slots = result.number_of_time_slots; - - this.timeSlots = this.reservationTableService.generateTimeSlots( - this.operationStart, - end, - slots - ); - }, - (error: Error) => { - // Handle the error here - this.snackBar.open( - 'Error fetching reservations', - 'Close', - this.snackBarOptions - ); - console.error('Error fetching reservations:', error); - } - ); - } - - //- Array to store information about selected cells, where each element is an object - //- with 'key' representing the room number and 'index' representing the time interval. - selectedCells: TableCell[] = []; - - /** - * Toggles the color of a cell in the reservations map and manages selected cells. - * - * @param {string} key - The key representing the room in the reservations map. - * @param {number} index - The index representing the time slot in the reservations map. - * @returns {void} The method does not return a value. - */ - toggleCellColor(key: string, index: number): void { - const isSelected = - this.reservationsMap[key][index] === - ReservationTableService.CellEnum.RESERVING; - - if (isSelected) { - this.reservationTableService.deselectCell(key, index, this); - } else { - this.reservationTableService.selectCell(key, index, this); - } - - this.selectButtonToggle(); - } - - //- Check if at least one time slot selected - selectButtonToggle(): void { - this.selectButton = Object.values(this.reservationsMap).some( - (timeSlotsForRow) => - timeSlotsForRow.includes(ReservationTableService.CellEnum.RESERVING) - ); - } - - /** - * Initiates the process of drafting a reservation based on the current state - * of the reservations map and the selected date. - * - * @throws {Error} If there is an exception during the drafting process. - * - * @remarks - * The method calls the 'draftReservation' service method and handles the response: - * - If the reservation is successfully drafted, the user is navigated to the - * confirmation page with the reservation data. - * - If there is an error during the drafting process, the error is logged, and an - * alert with the error message is displayed to the user. - * - * @example - * ```typescript - * draftReservation(); - * ``` - */ - - draftReservation() { - const result = this.reservationTableService.draftReservation( - this.reservationsMap, - this.operationStart - ); - result.subscribe( - (reservation: Reservation) => { - // Navigate with the reservation data - this.router.navigateByUrl( - `/coworking/confirm-reservation/${reservation.id}` - ); - }, - - (error) => { - // Handle errors here - console.error('Error drafting reservation', error); - this.snackBar.open(error.error.message, 'Close', this.snackBarOptions); - } - ); - } - - /** - * Setter for the reservations map to set the state of a timeslot to reserving. - * @param key room id for reservationsMap. - * @param index index of the timeslot to change the state of. - */ - public setSlotReserving(key: string, index: number) { - this.reservationsMap[key][index] = - ReservationTableService.CellEnum.RESERVING; - } - - /** - * Setter for the reservations map to set the state of a timeslot to reserved. - * @param key room id for reservationsMap. - * @param index index of the timeslot to change the state of. - */ - public setSlotAvailable(key: string, index: number) { - this.reservationsMap[key][index] = - ReservationTableService.CellEnum.AVAILABLE; - } -} diff --git a/frontend/src/app/event/events-page/events-page.component.css b/frontend/src/app/event/events-page/events-page.component.css index a99de496d..0d18d66ee 100644 --- a/frontend/src/app/event/events-page/events-page.component.css +++ b/frontend/src/app/event/events-page/events-page.component.css @@ -2,6 +2,7 @@ display: flex; flex-direction: row; width: 100%; + min-width: 100%; } .left-column { diff --git a/frontend/src/app/my-courses/my-courses-page/my-courses-page.component.css b/frontend/src/app/my-courses/my-courses-page/my-courses-page.component.css index d355782fb..381ca9d28 100644 --- a/frontend/src/app/my-courses/my-courses-page/my-courses-page.component.css +++ b/frontend/src/app/my-courses/my-courses-page/my-courses-page.component.css @@ -1,62 +1,62 @@ ::ng-deep .mat-pane { - margin-right: 32px !important; + margin-right: 32px !important; } .container { - height: 100%; - - ::ng-deep .mat-pane { - max-width: 100% !important; - min-height: calc(100vh - 112px); - margin-bottom: 32px !important; - } + height: 100%; + min-width: 100%; + ::ng-deep .mat-pane { + max-width: 100% !important; + min-height: calc(100vh - 112px); + margin-bottom: 32px !important; + } } #pane-header { - align-items: center !important; - padding-bottom: 16px !important; + align-items: center !important; + padding-bottom: 16px !important; } .header-buttons { - display: flex; - flex-direction: row; - margin-left: auto; - gap: 12px; + display: flex; + flex-direction: row; + margin-left: auto; + gap: 12px; } .course-card-container { - display: flex; - flex-direction: row; - flex-wrap: wrap; - row-gap: 24px; - column-gap: 24px; + display: flex; + flex-direction: row; + flex-wrap: wrap; + row-gap: 24px; + column-gap: 24px; } #more-courses-button { - margin-top: 12px; + margin-top: 12px; } .term-header { - margin-top: 16px; - margin-bottom: 16px; + margin-top: 16px; + margin-bottom: 16px; } @media only screen and (max-width: 640px) { - #pane-header { - display: flex; - flex-direction: column; - align-items: start !important; - } - .header-buttons { - margin-left: 0; - margin-top: 8px; - flex-direction: column; - align-items: start !important; - } + #pane-header { + display: flex; + flex-direction: column; + align-items: start !important; + } + .header-buttons { + margin-left: 0; + margin-top: 8px; + flex-direction: column; + align-items: start !important; + } } .no-courses-content { - display: flex; - flex-direction: column; - padding: 20px; -} \ No newline at end of file + display: flex; + flex-direction: column; + padding: 20px; +} diff --git a/frontend/src/app/shared/user-lookup/user-lookup.widget.html b/frontend/src/app/shared/user-lookup/user-lookup.widget.html index 0aea84068..14f1920a9 100644 --- a/frontend/src/app/shared/user-lookup/user-lookup.widget.html +++ b/frontend/src/app/shared/user-lookup/user-lookup.widget.html @@ -11,11 +11,13 @@ matChipAvatar [src]="user.github_avatar" /> {{ user.first_name + ' ' + user.last_name }} - + } = new EventEmitter(); diff --git a/frontend/src/app/welcome/welcome-page/welcome-page.component.css b/frontend/src/app/welcome/welcome-page/welcome-page.component.css index b008c4271..bd916e142 100644 --- a/frontend/src/app/welcome/welcome-page/welcome-page.component.css +++ b/frontend/src/app/welcome/welcome-page/welcome-page.component.css @@ -2,6 +2,7 @@ display: flex; flex-direction: row; width: 100%; + min-width: 100%; } .right-column { diff --git a/frontend/src/styles/styles.scss b/frontend/src/styles/styles.scss index a2899f94e..f5e30fd1a 100644 --- a/frontend/src/styles/styles.scss +++ b/frontend/src/styles/styles.scss @@ -148,6 +148,11 @@ body { color: mat.get-theme-color($theme, secondary) !important; } + .surface-container-low { + background-color: mat.get-theme-color($theme, surface-container-low) !important; + color: mat.get-theme-color($theme, on-surface) !important; + } + .surface-container-high { background-color: mat.get-theme-color($theme, surface-container-high) !important; color: mat.get-theme-color($theme, on-surface) !important; diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css new file mode 100644 index 000000000..d4b507858 --- /dev/null +++ b/frontend/src/styles/tailwind.css @@ -0,0 +1 @@ +@import 'tailwindcss';