diff --git a/README.md b/README.md index 3078e34ce..43638b547 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![](https://img.shields.io/badge/-teamby.team-important?style=flat&logo=airplayvideo&logoColor=white&labelColor=black&color=%233145FF)](https://teamby.team/) [![](https://img.shields.io/badge/-Tech%20Blog-important?style=flat&logo=angellist&logoColor=balck&labelColor=black&color=white) ](https://team-by-team.github.io/) -[![](https://img.shields.io/badge/release-v1.5.2-critical?style=flat&logo=github&logoColor=balck&labelColor=black&color=white) +[![](https://img.shields.io/badge/release-v1.6.0-critical?style=flat&logo=github&logoColor=balck&labelColor=black&color=white) ](https://github.com/woowacourse-teams/2023-team-by-team/releases) # 팀바팀 @@ -120,16 +120,16 @@ - + -| 팀 캘린더 | 팀 피드 | +| 팀 캘린더 | 팀 채팅 | | :---------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------: | -| | | +| | | | 팀 링크 | 팀 생성 및 팀 참가 | -| | | +| | |

팀바팀을 더 자세히 알고 싶다면, 여기로! diff --git a/backend/src/main/java/team/teamby/teambyteam/global/presentation/GlobalExceptionHandler.java b/backend/src/main/java/team/teamby/teambyteam/global/presentation/GlobalExceptionHandler.java index 6f3dbf76d..4e75bc844 100644 --- a/backend/src/main/java/team/teamby/teambyteam/global/presentation/GlobalExceptionHandler.java +++ b/backend/src/main/java/team/teamby/teambyteam/global/presentation/GlobalExceptionHandler.java @@ -123,6 +123,7 @@ public ResponseEntity handleCustomForbiddenException(final Runtim @ExceptionHandler(value = { ScheduleException.SpanWrongOrderException.class, + ScheduleException.dateFormatException.class, TeamPlaceInviteCodeException.LengthException.class, TeamPlaceException.NameLengthException.class, TeamPlaceException.NameBlankException.class, diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleService.java b/backend/src/main/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleService.java index 6ec6c8253..f3e36b309 100644 --- a/backend/src/main/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleService.java +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleService.java @@ -9,11 +9,13 @@ import team.teamby.teambyteam.member.domain.vo.Email; import team.teamby.teambyteam.member.exception.MemberException; import team.teamby.teambyteam.schedule.application.dto.SchedulesWithTeamPlaceIdResponse; +import team.teamby.teambyteam.schedule.application.parser.LocalDateParser; import team.teamby.teambyteam.schedule.domain.CalendarPeriod; import team.teamby.teambyteam.schedule.domain.Schedule; import team.teamby.teambyteam.schedule.domain.ScheduleRepository; import team.teamby.teambyteam.teamplace.domain.TeamPlace; +import java.time.LocalDate; import java.util.List; @Service @@ -23,6 +25,7 @@ public class MyCalendarScheduleService { private final MemberRepository memberRepository; private final ScheduleRepository scheduleRepository; + private final LocalDateParser localDateParser; @Transactional(readOnly = true) public SchedulesWithTeamPlaceIdResponse findScheduleInPeriod( @@ -66,4 +69,29 @@ public SchedulesWithTeamPlaceIdResponse findScheduleInPeriod( return SchedulesWithTeamPlaceIdResponse.of(dailySchedules); } + + @Transactional(readOnly = true) + public SchedulesWithTeamPlaceIdResponse findScheduleInPeriod( + final MemberEmailDto memberEmailDto, + final String startDateString, + final String endDateString + ) { + final Member member = memberRepository.findByEmail(new Email(memberEmailDto.email())) + .orElseThrow(() -> new MemberException.MemberNotFoundException(memberEmailDto.email())); + + final List participatedTeamPlaceIds = member.getTeamPlaces() + .stream() + .map(TeamPlace::getId) + .toList(); + + final LocalDate startDate = localDateParser.parse(startDateString); + final LocalDate endDate = localDateParser.parse(endDateString); + + final CalendarPeriod period = CalendarPeriod.of(startDate, endDate); + + final List dailySchedules = scheduleRepository.findAllByTeamPlaceIdAndPeriod( + participatedTeamPlaceIds, period.startDateTime(), period.endDatetime()); + + return SchedulesWithTeamPlaceIdResponse.of(dailySchedules); + } } diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleService.java b/backend/src/main/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleService.java index 83fd90d06..2bb969263 100644 --- a/backend/src/main/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleService.java +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleService.java @@ -13,6 +13,7 @@ import team.teamby.teambyteam.schedule.application.event.ScheduleDeleteEvent; import team.teamby.teambyteam.schedule.application.event.ScheduleUpdateEvent; import team.teamby.teambyteam.schedule.application.event.ScheduleUpdateEventDto; +import team.teamby.teambyteam.schedule.application.parser.LocalDateParser; import team.teamby.teambyteam.schedule.domain.CalendarPeriod; import team.teamby.teambyteam.schedule.domain.Schedule; import team.teamby.teambyteam.schedule.domain.ScheduleRepository; @@ -22,6 +23,7 @@ import team.teamby.teambyteam.teamplace.domain.TeamPlaceRepository; import team.teamby.teambyteam.teamplace.exception.TeamPlaceException; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -34,6 +36,7 @@ public class TeamCalendarScheduleService { private final ScheduleRepository scheduleRepository; private final TeamPlaceRepository teamPlaceRepository; private final ApplicationEventPublisher applicationEventPublisher; + private final LocalDateParser localDateParser; public Long register(final ScheduleRegisterRequest scheduleRegisterRequest, final Long teamPlaceId) { checkTeamPlaceExist(teamPlaceId); @@ -87,7 +90,7 @@ private boolean isNotScheduleOfTeam(final Long teamPlaceId, final Schedule sched } @Transactional(readOnly = true) - public SchedulesResponse findScheduleInPeriod(final Long teamPlaceId, final int targetYear, final int targetMonth) { + public SchedulesResponse findScheduleInMonth(final Long teamPlaceId, final int targetYear, final int targetMonth) { checkTeamPlaceExist(teamPlaceId); final CalendarPeriod period = CalendarPeriod.of(targetYear, targetMonth); @@ -98,7 +101,7 @@ public SchedulesResponse findScheduleInPeriod(final Long teamPlaceId, final int } @Transactional(readOnly = true) - public SchedulesResponse findScheduleInPeriod( + public SchedulesResponse findScheduleInDay( final Long teamPlaceId, final int targetYear, final int targetMonth, @@ -113,6 +116,24 @@ public SchedulesResponse findScheduleInPeriod( return SchedulesResponse.of(dailySchedules); } + @Transactional(readOnly = true) + public SchedulesResponse findScheduleInPeriod( + final Long teaPlaceId, + final String startDateString, + final String endDateString + ) { + checkTeamPlaceExist(teaPlaceId); + + final LocalDate startDate = localDateParser.parse(startDateString); + final LocalDate endDate = localDateParser.parse(endDateString); + final CalendarPeriod period = CalendarPeriod.of(startDate, endDate); + + final List schedules = scheduleRepository. + findAllByTeamPlaceIdAndPeriod(teaPlaceId, period.startDateTime(), period.endDatetime()); + + return SchedulesResponse.of(schedules); + } + public void update(final ScheduleUpdateRequest scheduleUpdateRequest, final Long teamPlaceId, final Long scheduleId) { checkTeamPlaceExist(teamPlaceId); diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/application/parser/LocalDateParser.java b/backend/src/main/java/team/teamby/teambyteam/schedule/application/parser/LocalDateParser.java new file mode 100644 index 000000000..ab470bb02 --- /dev/null +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/application/parser/LocalDateParser.java @@ -0,0 +1,22 @@ +package team.teamby.teambyteam.schedule.application.parser; + +import org.springframework.stereotype.Component; +import team.teamby.teambyteam.schedule.exception.ScheduleException; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +@Component +public class LocalDateParser { + + private static final DateTimeFormatter DATE_PARAM_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + public LocalDate parse(final String yearMonthDay) { + try { + return LocalDate.parse(yearMonthDay, DATE_PARAM_FORMAT); + } catch (final DateTimeParseException e) { + throw new ScheduleException.dateFormatException(e); + } + } +} diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/domain/CalendarPeriod.java b/backend/src/main/java/team/teamby/teambyteam/schedule/domain/CalendarPeriod.java index 5af8c105e..a268cd957 100644 --- a/backend/src/main/java/team/teamby/teambyteam/schedule/domain/CalendarPeriod.java +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/domain/CalendarPeriod.java @@ -1,9 +1,18 @@ package team.teamby.teambyteam.schedule.domain; +import team.teamby.teambyteam.schedule.exception.ScheduleException; + import java.time.LocalDate; import java.time.LocalDateTime; import java.time.LocalTime; +/** + * 캘린더 일정 + * 일정에 해당하려면 startDateTime <= PERIOD < endDateTime + * + * @param startDateTime inclusive DateTime + * @param endDatetime exclusive DateTime + */ public record CalendarPeriod( LocalDateTime startDateTime, LocalDateTime endDatetime @@ -26,4 +35,21 @@ public static CalendarPeriod of(final int year, final int month, final int day) return new CalendarPeriod(LocalDateTime.of(dailyDate, START_TIME_OF_DAY), LocalDateTime.of(nextDay, START_TIME_OF_DAY)); } + + public static CalendarPeriod of(final LocalDate startDate, final LocalDate endDate) { + validateOrder(startDate, endDate); + return new CalendarPeriod( + LocalDateTime.of(startDate, START_TIME_OF_DAY), + LocalDateTime.of(endDate.plusDays(NEXT_DAY_OFFSET), START_TIME_OF_DAY) + ); + } + + private static void validateOrder(final LocalDate startDate, final LocalDate endDate) { + if (endDate.isBefore(startDate)) { + throw new ScheduleException.SpanWrongOrderException( + LocalDateTime.of(startDate, START_TIME_OF_DAY), + LocalDateTime.of(endDate, START_TIME_OF_DAY) + ); + } + } } diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/exception/ScheduleException.java b/backend/src/main/java/team/teamby/teambyteam/schedule/exception/ScheduleException.java index e6082c286..11d5af703 100644 --- a/backend/src/main/java/team/teamby/teambyteam/schedule/exception/ScheduleException.java +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/exception/ScheduleException.java @@ -8,6 +8,10 @@ public ScheduleException(final String message) { super(message); } + public ScheduleException(String message, Throwable cause) { + super(message, cause); + } + public static class ScheduleNotFoundException extends ScheduleException { public ScheduleNotFoundException(final Long scheduleId) { super(String.format("조회한 일정이 존재하지 않습니다. - request info { schedule_id : %d }", scheduleId)); @@ -31,9 +35,14 @@ public SpanWrongOrderException(final LocalDateTime startDateTime, final LocalDat } public static class TitleBlankException extends ScheduleException { - public TitleBlankException() { super("일정의 제목은 빈 칸일 수 없습니다."); } } + + public static class dateFormatException extends ScheduleException { + public dateFormatException(final Exception e) { + super("잘못된 날짜 입력 형식입니다.", e); + } + } } diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/MyCalendarScheduleController.java b/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/MyCalendarScheduleController.java index 16b7f9705..63edcbc6d 100644 --- a/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/MyCalendarScheduleController.java +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/MyCalendarScheduleController.java @@ -40,4 +40,15 @@ public ResponseEntity findDailySchedule( return ResponseEntity.ok(responseBody); } + + @GetMapping(value = "/schedules", params = {"startDate", "endDate"}) + public ResponseEntity findDailySchedule( + @AuthPrincipal final MemberEmailDto memberEmailDto, + @RequestParam(value = "startDate") final String startDate, + @RequestParam(value = "endDate") final String endDate + ) { + final SchedulesWithTeamPlaceIdResponse responseBody = myCalendarScheduleService.findScheduleInPeriod(memberEmailDto, startDate, endDate); + + return ResponseEntity.ok(responseBody); + } } diff --git a/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/TeamCalendarScheduleController.java b/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/TeamCalendarScheduleController.java index fa758f81b..f5f5694c7 100644 --- a/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/TeamCalendarScheduleController.java +++ b/backend/src/main/java/team/teamby/teambyteam/schedule/presentation/TeamCalendarScheduleController.java @@ -38,12 +38,12 @@ public ResponseEntity findSpecificSchedule( } @GetMapping(value = "/{teamPlaceId}/calendar/schedules", params = {"year", "month"}) - public ResponseEntity findSchedulesInPeriod( + public ResponseEntity findScheduleInMonth( @PathVariable final Long teamPlaceId, @RequestParam final Integer year, @RequestParam final Integer month ) { - final SchedulesResponse responseBody = teamCalendarScheduleService.findScheduleInPeriod(teamPlaceId, year, month); + final SchedulesResponse responseBody = teamCalendarScheduleService.findScheduleInMonth(teamPlaceId, year, month); return ResponseEntity.ok(responseBody); } @@ -55,7 +55,18 @@ public ResponseEntity findDailySchedule( @RequestParam final Integer month, @RequestParam final Integer day ) { - final SchedulesResponse response = teamCalendarScheduleService.findScheduleInPeriod(teamPlaceId, year, month, day); + final SchedulesResponse response = teamCalendarScheduleService.findScheduleInDay(teamPlaceId, year, month, day); + + return ResponseEntity.ok(response); + } + + @GetMapping(value = "/{teamPlaceId}/calendar/schedules", params = {"startDate", "endDate"}) + public ResponseEntity findDailySchedule( + @PathVariable final Long teamPlaceId, + @RequestParam(value = "startDate") final String startDate, + @RequestParam(value = "endDate") final String endDate + ) { + final SchedulesResponse response = teamCalendarScheduleService.findScheduleInPeriod(teamPlaceId, startDate, endDate); return ResponseEntity.ok(response); } diff --git a/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/MyCalendarScheduleAcceptanceFixtures.java b/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/MyCalendarScheduleAcceptanceFixtures.java index e489570d0..1bedc1a59 100644 --- a/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/MyCalendarScheduleAcceptanceFixtures.java +++ b/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/MyCalendarScheduleAcceptanceFixtures.java @@ -21,6 +21,21 @@ public static ExtractableResponse FIND_PERIOD_SCHEDULE_REQUEST(final S .extract(); } + public static ExtractableResponse FIND_PERIOD_SCHEDULE_REQUEST( + final String token, + final String startDate, + final String endDate + ) { + return RestAssured.given().log().all() + .header(new Header(HttpHeaders.AUTHORIZATION, JWT_PREFIX + token)) + .queryParam("startDate", startDate) + .queryParam("endDate", endDate) + .when().log().all() + .get("/api/my-calendar/schedules") + .then().log().all() + .extract(); + } + public static ExtractableResponse FIND_DAILY_SCHEDULE_REQUEST(final String token, final Integer year, final Integer month, final Integer day) { return RestAssured.given().log().all() .header(new Header(HttpHeaders.AUTHORIZATION, JWT_PREFIX + token)) diff --git a/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/TeamCalendarScheduleAcceptanceFixtures.java b/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/TeamCalendarScheduleAcceptanceFixtures.java index 3c6b9f615..4c868b1ff 100644 --- a/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/TeamCalendarScheduleAcceptanceFixtures.java +++ b/backend/src/test/java/team/teamby/teambyteam/common/fixtures/acceptance/TeamCalendarScheduleAcceptanceFixtures.java @@ -44,6 +44,18 @@ public static ExtractableResponse FIND_PERIOD_SCHEDULE_REQUEST(final S .extract(); } + public static ExtractableResponse FIND_PERIOD_SCHEDULE_REQUEST(final String token, final Long teamPlaceId, final String startDate, final String endDate) { + return RestAssured.given().log().all() + .header(new Header(HttpHeaders.AUTHORIZATION, JWT_PREFIX + token)) + .pathParam("teamPlaceId", teamPlaceId) + .queryParam("startDate", startDate) + .queryParam("endDate", endDate) + .when().log().all() + .get("/api/team-place/{teamPlaceId}/calendar/schedules") + .then().log().all() + .extract(); + } + public static ExtractableResponse FIND_DAILY_SCHEDULE_REQUEST(final String token, final Long teamPlaceId, final int year, final int month, final int day) { return RestAssured.given().log().all() .header(new Header(HttpHeaders.AUTHORIZATION, JWT_PREFIX + token)) diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/MyCalendarScheduleAcceptanceTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/MyCalendarScheduleAcceptanceTest.java index 8e9c16600..331426037 100644 --- a/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/MyCalendarScheduleAcceptanceTest.java +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/MyCalendarScheduleAcceptanceTest.java @@ -100,4 +100,107 @@ void success() { }); } } + + + + @Nested + @DisplayName("내 캘린더 일정을 특정 기간 사이에서 조회를 한다") + class MyCalendarFindScheduleInSpecificPeriod { + + @Test + @DisplayName("기간으로 조회 성공한다.") + void success() { + // given + final Member PHILIP = testFixtureBuilder.buildMember(PHILIP()); + + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final TeamPlace JAPANESE_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(JAPANESE_TEAM_PLACE()); + + testFixtureBuilder.buildMemberTeamPlace(PHILIP, ENGLISH_TEAM_PLACE); + testFixtureBuilder.buildMemberTeamPlace(PHILIP, JAPANESE_TEAM_PLACE); + + final Schedule MONTH_6_AND_MONTH_7_DAY_12_ENGLISH_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + final Schedule MONTH_7_AND_DAY_12_ALL_DAY_ENGLISH_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + final Schedule MONTH_7_AND_DAY_12_N_HOUR_JAPANESE_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(JAPANESE_TEAM_PLACE.getId())); + final Schedule MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE_JAPANESE_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE(JAPANESE_TEAM_PLACE.getId())); + + final String startDate = "20230711"; + final String endDate = "20230712"; + + // when + final ExtractableResponse response = FIND_PERIOD_SCHEDULE_REQUEST(jwtTokenProvider.generateAccessToken(PHILIP.getEmail().getValue()), startDate, endDate); + final List schedules = response.jsonPath().getList("schedules", ScheduleWithTeamPlaceIdResponse.class); + + //then + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(schedules).hasSize(3); + softly.assertThat(schedules.get(0).title()).isEqualTo(MONTH_6_AND_MONTH_7_DAY_12_ENGLISH_SCHEDULE.getTitle().getValue()); + softly.assertThat(schedules.get(1).title()).isEqualTo(MONTH_7_AND_DAY_12_ALL_DAY_ENGLISH_SCHEDULE.getTitle().getValue()); + softly.assertThat(schedules.get(2).title()).isEqualTo(MONTH_7_AND_DAY_12_N_HOUR_JAPANESE_SCHEDULE.getTitle().getValue()); + }); + } + + @Test + @DisplayName("날짜의 순서가 잘못되면 실패한다.") + void failWithWrongPeriodDateOrder() { + // given + final Member PHILIP = testFixtureBuilder.buildMember(PHILIP()); + + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final TeamPlace JAPANESE_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(JAPANESE_TEAM_PLACE()); + + testFixtureBuilder.buildMemberTeamPlace(PHILIP, ENGLISH_TEAM_PLACE); + testFixtureBuilder.buildMemberTeamPlace(PHILIP, JAPANESE_TEAM_PLACE); + + testFixtureBuilder.buildSchedule(MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(JAPANESE_TEAM_PLACE.getId())); + testFixtureBuilder.buildSchedule(MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE(JAPANESE_TEAM_PLACE.getId())); + + final String startDate = "20230711"; + final String endDate = "20230710"; + + // when + final ExtractableResponse response = FIND_PERIOD_SCHEDULE_REQUEST(jwtTokenProvider.generateAccessToken(PHILIP.getEmail().getValue()), startDate, endDate); + final String errorMessage = response.jsonPath().get("error"); + + //then + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + softly.assertThat(errorMessage).contains("시작 일자가 종료 일자보다 이후일 수 없습니다."); + }); + } + + @Test + @DisplayName("잘못된 형식으로 조회요청시 실패한다") + void failWithWrongDateFormant() { + // given + final Member PHILIP = testFixtureBuilder.buildMember(PHILIP()); + + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final TeamPlace JAPANESE_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(JAPANESE_TEAM_PLACE()); + + testFixtureBuilder.buildMemberTeamPlace(PHILIP, ENGLISH_TEAM_PLACE); + testFixtureBuilder.buildMemberTeamPlace(PHILIP, JAPANESE_TEAM_PLACE); + + testFixtureBuilder.buildSchedule(MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(JAPANESE_TEAM_PLACE.getId())); + testFixtureBuilder.buildSchedule(MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE(JAPANESE_TEAM_PLACE.getId())); + + final String startDate = "2023-07-11"; + final String endDate = "20230712"; + + // when + final ExtractableResponse response = FIND_PERIOD_SCHEDULE_REQUEST(jwtTokenProvider.generateAccessToken(PHILIP.getEmail().getValue()), startDate, endDate); + final String errorMessage = response.jsonPath().get("error"); + + //then + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + softly.assertThat(errorMessage).isEqualTo("잘못된 날짜 입력 형식입니다."); + }); + } + } } diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/TeamCalendarScheduleAcceptanceTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/TeamCalendarScheduleAcceptanceTest.java index 2db8d5719..23a65d5fe 100644 --- a/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/TeamCalendarScheduleAcceptanceTest.java +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/acceptance/TeamCalendarScheduleAcceptanceTest.java @@ -34,10 +34,22 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import static team.teamby.teambyteam.common.fixtures.MemberFixtures.PHILIP; import static team.teamby.teambyteam.common.fixtures.MemberTeamPlaceFixtures.PHILIP_ENGLISH_TEAM_PLACE; -import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.*; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE_REGISTER_REQUEST; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE_TITLE; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE_UPDATE_REQUEST; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_DAY_29_AND_MONTH_8_SCHEDULE; import static team.teamby.teambyteam.common.fixtures.TeamPlaceFixtures.ENGLISH_TEAM_PLACE; import static team.teamby.teambyteam.common.fixtures.TeamPlaceFixtures.JAPANESE_TEAM_PLACE; -import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.*; +import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.DELETE_SCHEDULE_REQUEST; +import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.FIND_DAILY_SCHEDULE_REQUEST; +import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.FIND_PERIOD_SCHEDULE_REQUEST; +import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.FIND_SPECIFIC_SCHEDULE_REQUEST; +import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.REGISTER_SCHEDULE_REQUEST; +import static team.teamby.teambyteam.common.fixtures.acceptance.TeamCalendarScheduleAcceptanceFixtures.UPDATE_SCHEDULE_REQUEST; public class TeamCalendarScheduleAcceptanceTest extends AcceptanceTest { @@ -129,7 +141,7 @@ void failMemberHasNotTeamPlaceSchedule() { @Nested @DisplayName("팀 캘린더 내 기간 일정 조회 시") - class FindTeamCalendarScheduleInPeriod { + class FindTeamCalendarScheduleInMonthlyPeriod { @Test @DisplayName("기간으로 조회 성공한다.") @@ -211,6 +223,79 @@ void failWithWrongQuery() { } } + @Nested + @DisplayName("팀 캘린더 기간 지정 일정 조회 시") + class FindTeamCalendarScheduleInSpecificPeriod { + + @Test + @DisplayName("기간으로 조회 성공한다.") + void success() { + // given + final Member PHILIP = testFixtureBuilder.buildMember(PHILIP()); + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final MemberTeamPlace PHILIP_ENGLISH_TEAM_PLACE = PHILIP_ENGLISH_TEAM_PLACE(); + PHILIP_ENGLISH_TEAM_PLACE.setMemberAndTeamPlace(PHILIP, ENGLISH_TEAM_PLACE); + testFixtureBuilder.buildMemberTeamPlace(PHILIP_ENGLISH_TEAM_PLACE); + + final List schedulesToSave = List.of( + MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_DAY_29_AND_MONTH_8_SCHEDULE(ENGLISH_TEAM_PLACE.getId()) + ); + final List expectedSchedules = testFixtureBuilder.buildSchedules(schedulesToSave); + final String startDate = "20230712"; + final String endDate = "20230728"; + + // when + final ExtractableResponse response = FIND_PERIOD_SCHEDULE_REQUEST(jwtTokenProvider.generateAccessToken(PHILIP.getEmail().getValue()), ENGLISH_TEAM_PLACE.getId(), startDate, endDate); + final List actualSchedules = response.jsonPath().getList("schedules", ScheduleResponse.class); + + //then + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + softly.assertThat(actualSchedules.size()).isEqualTo(4); + softly.assertThat(actualSchedules.get(0).title()).isEqualTo(expectedSchedules.get(0).getTitle().getValue()); + softly.assertThat(actualSchedules.get(1).title()).isEqualTo(expectedSchedules.get(1).getTitle().getValue()); + softly.assertThat(actualSchedules.get(2).title()).isEqualTo(expectedSchedules.get(2).getTitle().getValue()); + softly.assertThat(actualSchedules.get(3).title()).isEqualTo(expectedSchedules.get(3).getTitle().getValue()); + }); + } + + @Test + @DisplayName("잘못된 날짜 형식으로 요청시 실패한다.") + void failWithWrongFormat() { + // given + final Member PHILIP = testFixtureBuilder.buildMember(PHILIP()); + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final MemberTeamPlace PHILIP_ENGLISH_TEAM_PLACE = PHILIP_ENGLISH_TEAM_PLACE(); + PHILIP_ENGLISH_TEAM_PLACE.setMemberAndTeamPlace(PHILIP, ENGLISH_TEAM_PLACE); + testFixtureBuilder.buildMemberTeamPlace(PHILIP_ENGLISH_TEAM_PLACE); + + final List schedulesToSave = List.of( + MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_DAY_29_AND_MONTH_8_SCHEDULE(ENGLISH_TEAM_PLACE.getId()) + ); + testFixtureBuilder.buildSchedules(schedulesToSave); + final String startDate = "2023-07-12"; + final String endDate = "2023-07-28"; + + // when + final ExtractableResponse response = FIND_PERIOD_SCHEDULE_REQUEST(jwtTokenProvider.generateAccessToken(PHILIP.getEmail().getValue()), ENGLISH_TEAM_PLACE.getId(), startDate, endDate); + final String errorMessage = response.jsonPath().get("error"); + + //then + assertSoftly(softly -> { + softly.assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST.value()); + softly.assertThat(errorMessage).isEqualTo("잘못된 날짜 입력 형식입니다."); + }); + } + } + @Nested @DisplayName("팀 캘린더 하루 일정 조회 시") class FindTeamCalendarDailySchedule { @@ -499,7 +584,6 @@ private ExtractableResponse wrongDateTimeTypeRegisterScheduleRequest(f } } - @Nested @DisplayName("일정 수정 시") class UpdateSchedule { diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/application/LocalDateParserTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/application/LocalDateParserTest.java new file mode 100644 index 000000000..47b9bcdd4 --- /dev/null +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/application/LocalDateParserTest.java @@ -0,0 +1,46 @@ +package team.teamby.teambyteam.schedule.application; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import team.teamby.teambyteam.schedule.application.parser.LocalDateParser; +import team.teamby.teambyteam.schedule.exception.ScheduleException; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LocalDateParserTest { + + private final LocalDateParser localDateParser = new LocalDateParser(); + + @Test + @DisplayName("LocalDate 파싱을 성공한다") + void success() { + // given + final String input = "20230102"; + + // when + final LocalDate actual = localDateParser.parse(input); + + // then + assertThat(actual).isEqualTo(LocalDate.of(2023, 1, 2)); + } + + @ParameterizedTest + @ValueSource(strings = {"2023-01-01", "2023721", "20230132"}) + @DisplayName("yyyyMMdd형식이 아닌 경우 예외가 발생한다.") + void failWithWrongFormat(final String input) { + // given + + // when + // then + assertThatThrownBy(() -> localDateParser.parse(input)) + .isInstanceOf(ScheduleException.dateFormatException.class) + .hasMessage("잘못된 날짜 입력 형식입니다."); + + } + +} diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleServiceTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleServiceTest.java index db0723229..5d80ec35a 100644 --- a/backend/src/test/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleServiceTest.java +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/application/MyCalendarScheduleServiceTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static team.teamby.teambyteam.common.fixtures.MemberFixtures.PHILIP; +import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_5_LAST_DAY_SCHEDULE; import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE; import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_6_AND_MONTH_7_SCHEDULE; import static team.teamby.teambyteam.common.fixtures.ScheduleFixtures.MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE; @@ -128,4 +129,43 @@ void successEmptyList() { assertThat(dailyScheduleResponse.schedules()).hasSize(0); } } + + @Nested + @DisplayName("특정 기간 안에서 내 캘린더 정보 조회 시") + class FindScheduleInMyCalendarInSpecificPeriod { + + @Test + @DisplayName("내 캘린더 정보 조회를 성공한다.") + void success() { + // given + final Member PHILIP = testFixtureBuilder.buildMember(PHILIP()); + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final TeamPlace JAPANESE_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(JAPANESE_TEAM_PLACE()); + + testFixtureBuilder.buildMemberTeamPlace(PHILIP, ENGLISH_TEAM_PLACE); + testFixtureBuilder.buildMemberTeamPlace(PHILIP, JAPANESE_TEAM_PLACE); + + final List expectedSchedules = List.of( + MONTH_5_LAST_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_AND_DAY_12_ALL_DAY_SCHEDULE(ENGLISH_TEAM_PLACE.getId()), + MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(JAPANESE_TEAM_PLACE.getId()) + ); + testFixtureBuilder.buildSchedules(expectedSchedules); + + final MemberEmailDto memberEmailDto = new MemberEmailDto(PHILIP.getEmail().getValue()); + final String startDate = "20230601"; + final String endDate = "20230712"; + + // when + final SchedulesWithTeamPlaceIdResponse scheduleInPeriod = myCalendarScheduleService.findScheduleInPeriod(memberEmailDto, startDate, endDate); + final List scheduleResponses = scheduleInPeriod.schedules(); + + //then + assertSoftly(softly -> { + softly.assertThat(scheduleResponses).hasSize(2); + softly.assertThat(scheduleResponses.get(0).title()).isEqualTo(expectedSchedules.get(1).getTitle().getValue()); + softly.assertThat(scheduleResponses.get(1).title()).isEqualTo(expectedSchedules.get(2).getTitle().getValue()); + }); + } + } } diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleServiceTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleServiceTest.java index 7ec53b1ed..8f81f0833 100644 --- a/backend/src/test/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleServiceTest.java +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/application/TeamCalendarScheduleServiceTest.java @@ -4,6 +4,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import team.teamby.teambyteam.common.ServiceTest; @@ -108,8 +109,8 @@ void failFindOtherTeamPlaceSchedule() { } @Test - @DisplayName("팀 캘린더에서 특정 기간 내 일정들을 조회한다.") - void findAllInPeriod() { + @DisplayName("팀 캘린더에서 1달 내 일정들을 조회한다.") + void findAllInMonthlyPeriod() { // given final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); final Schedule MONTH_6_AND_MONTH_7_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); @@ -121,7 +122,7 @@ void findAllInPeriod() { final int month = 7; // when - final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE.getId(), year, month); + final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInMonth(ENGLISH_TEAM_PLACE.getId(), year, month); final List scheduleResponses = schedulesResponse.schedules(); //then @@ -134,6 +135,47 @@ void findAllInPeriod() { }); } + @Test + @DisplayName("팀 캘린더에서 입력된 기간내 일정들을 조회한다.") + void findAllInSpecificPeriod() { + // given + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + final Schedule MONTH_6_AND_MONTH_7_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_6_AND_MONTH_7_DAY_12_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + final Schedule MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + final Schedule MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + final Schedule MONTH_7_DAY_29_AND_MONTH_8_SCHEDULE = testFixtureBuilder.buildSchedule(MONTH_7_DAY_29_AND_MONTH_8_SCHEDULE(ENGLISH_TEAM_PLACE.getId())); + + final String startDate = "20230712"; + final String endDate = "20230728"; + + // when + final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE.getId(), startDate, endDate); + final List scheduleResponses = schedulesResponse.schedules(); + + //then + assertSoftly(softly -> { + softly.assertThat(scheduleResponses).hasSize(3); + softly.assertThat(scheduleResponses.get(0).title()).isEqualTo(MONTH_6_AND_MONTH_7_SCHEDULE.getTitle().getValue()); + softly.assertThat(scheduleResponses.get(1).title()).isEqualTo(MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE.getTitle().getValue()); + softly.assertThat(scheduleResponses.get(2).title()).isEqualTo(MONTH_7_DAY_28_AND_MONTH_8_SCHEDULE.getTitle().getValue()); + }); + } + + @ParameterizedTest + @CsvSource(value = {"2023712,20230728", "20230712,0728"}, delimiter = ',') + @DisplayName("특정기간 조회시 일정 포멧이 yyyyMMdd와 다르면 예외를 발생시킨다.") + void failWithWrongDateFormat(final String startDate, final String endDate) { + // given + final TeamPlace ENGLISH_TEAM_PLACE = testFixtureBuilder.buildTeamPlace(ENGLISH_TEAM_PLACE()); + + // when + // then + assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE.getId(), startDate, endDate)) + .isInstanceOf(ScheduleException.dateFormatException.class) + .hasMessage("잘못된 날짜 입력 형식입니다."); + + } + @Test @DisplayName("팀 캘린더에서 일정이 없는 기간 내 일정들을 조회한다.") void findAllInPeriodWith0Schedule() { @@ -143,7 +185,7 @@ void findAllInPeriodWith0Schedule() { final int month = 7; // when - final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE.getId(), notExistYear, month); + final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInMonth(ENGLISH_TEAM_PLACE.getId(), notExistYear, month); final List scheduleResponses = schedulesResponse.schedules(); //then @@ -162,7 +204,7 @@ void firstAndLastDateScheduleFind() { final int month = MONTH_5_FIRST_DAY_SCHEDULE.getSpan().getStartDateTime().getMonthValue(); // when - final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE.getId(), year, month); + final SchedulesResponse schedulesResponse = teamCalendarScheduleService.findScheduleInMonth(ENGLISH_TEAM_PLACE.getId(), year, month); final List scheduleResponses = schedulesResponse.schedules(); //then @@ -194,7 +236,7 @@ void success() { // when final SchedulesResponse dailySchedulesResponse = - teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE_ID, year, month, day); + teamCalendarScheduleService.findScheduleInDay(ENGLISH_TEAM_PLACE_ID, year, month, day); final List dailyTeamCalendarSchedulesResponses = dailySchedulesResponse.schedules(); // then @@ -226,7 +268,7 @@ void wrongYear() { final int day = MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE.getSpan().getStartDateTime().getDayOfMonth(); // when & then - assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE_ID, wrongYear, month, day)) + assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInDay(ENGLISH_TEAM_PLACE_ID, wrongYear, month, day)) .isInstanceOf(DateTimeException.class) .hasMessageContaining("Invalid value for Year (valid values -999999999 - 999999999)"); } @@ -243,7 +285,7 @@ void wrongMonth() { final int day = MONTH_7_AND_DAY_12_N_HOUR_SCHEDULE.getSpan().getStartDateTime().getDayOfMonth(); // when & then - assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE_ID, year, wrongMonth, day)) + assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInDay(ENGLISH_TEAM_PLACE_ID, year, wrongMonth, day)) .isInstanceOf(DateTimeException.class) .hasMessageContaining("Invalid value for MonthOfYear (valid values 1 - 12)"); } @@ -260,7 +302,7 @@ void wrongDay() { final int wrongDay = -1; // when & then - assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE_ID, year, month, wrongDay)) + assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInDay(ENGLISH_TEAM_PLACE_ID, year, month, wrongDay)) .isInstanceOf(DateTimeException.class) .hasMessageContaining("Invalid value for DayOfMonth (valid values 1 - 28/31)"); } @@ -276,7 +318,7 @@ void successNotExistSchedule() { final int day = 1; // when - SchedulesResponse schedules = teamCalendarScheduleService.findScheduleInPeriod(ENGLISH_TEAM_PLACE.getId(), year, month, day); + SchedulesResponse schedules = teamCalendarScheduleService.findScheduleInDay(ENGLISH_TEAM_PLACE.getId(), year, month, day); // then assertThat(schedules.schedules()).hasSize(0); @@ -292,7 +334,7 @@ void failTeamPlaceNotExist() { final int day = 1; // when & then - assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInPeriod(notExistTeamPlaceId, year, month, day)) + assertThatThrownBy(() -> teamCalendarScheduleService.findScheduleInDay(notExistTeamPlaceId, year, month, day)) .isInstanceOf(TeamPlaceException.NotFoundException.class) .hasMessageContaining("조회한 팀 플레이스가 존재하지 않습니다."); } diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/docs/TeamCalendarScheduleApiDocsTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/docs/TeamCalendarScheduleApiDocsTest.java index 01ccf2cfd..0c630f5ec 100644 --- a/backend/src/test/java/team/teamby/teambyteam/schedule/docs/TeamCalendarScheduleApiDocsTest.java +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/docs/TeamCalendarScheduleApiDocsTest.java @@ -426,7 +426,7 @@ void success() throws Exception { List schedules = List.of(schedule1, schedule2); SchedulesResponse response = SchedulesResponse.of(schedules); - given(teamCalendarScheduleService.findScheduleInPeriod(teamPlaceId, 2023, 7)) + given(teamCalendarScheduleService.findScheduleInMonth(teamPlaceId, 2023, 7)) .willReturn(response); // when & then @@ -469,7 +469,7 @@ void failNotExistTeamplaceId() throws Exception { willThrow(new TeamPlaceException.NotFoundException(teamPlaceId)) .given(teamCalendarScheduleService) - .findScheduleInPeriod(teamPlaceId, 2023, 7); + .findScheduleInMonth(teamPlaceId, 2023, 7); // when & then mockMvc.perform(get("/api/team-place/{teamPlaceId}/calendar/schedules", teamPlaceId) @@ -504,7 +504,7 @@ void success() throws Exception { List schedules = List.of(schedule1, schedule2); SchedulesResponse response = SchedulesResponse.of(schedules); - given(teamCalendarScheduleService.findScheduleInPeriod(teamPlaceId, 2023, 7, 12)) + given(teamCalendarScheduleService.findScheduleInDay(teamPlaceId, 2023, 7, 12)) .willReturn(response); // when & then @@ -549,7 +549,7 @@ void failNotExistTeamplaceId() throws Exception { willThrow(new TeamPlaceException.NotFoundException(teamPlaceId)) .given(teamCalendarScheduleService) - .findScheduleInPeriod(teamPlaceId, 2023, 7, 12); + .findScheduleInDay(teamPlaceId, 2023, 7, 12); // when & then mockMvc.perform(get("/api/team-place/{teamPlaceId}/calendar/schedules", teamPlaceId) diff --git a/backend/src/test/java/team/teamby/teambyteam/schedule/domain/CalendarPeriodTest.java b/backend/src/test/java/team/teamby/teambyteam/schedule/domain/CalendarPeriodTest.java new file mode 100644 index 000000000..5407915ab --- /dev/null +++ b/backend/src/test/java/team/teamby/teambyteam/schedule/domain/CalendarPeriodTest.java @@ -0,0 +1,46 @@ +package team.teamby.teambyteam.schedule.domain; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import team.teamby.teambyteam.schedule.exception.ScheduleException; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static team.teamby.teambyteam.schedule.domain.CalendarPeriod.of; + +class CalendarPeriodTest { + + @Test + @DisplayName("LocalDate로 생성 테스트") + void createWithLocalDate() { + // given + final LocalDate startDate = LocalDate.of(2023, 1, 1); + final LocalDate endDate = LocalDate.of(2023, 1, 1); + + // when + final CalendarPeriod calendarPeriod = of(startDate, endDate); + + // then + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(calendarPeriod.startDateTime()).isEqualTo(LocalDateTime.of(2023, 1, 1, 0, 0, 0)); + softly.assertThat(calendarPeriod.endDatetime()).isEqualTo(LocalDateTime.of(2023, 1, 2, 0, 0, 0)); + }); + } + + @Test + @DisplayName("시작일보다 이른 종료일로 생성시 예외 발생") + void exceptionWithWrongPeriodOrder() { + // given + final LocalDate startDate = LocalDate.of(2023, 1, 2); + final LocalDate endDate = LocalDate.of(2023, 1, 1); + + // when + // then + assertThatThrownBy(() -> of(startDate, endDate)) + .isInstanceOf(ScheduleException.SpanWrongOrderException.class) + .hasMessageContaining("시작 일자가 종료 일자보다 이후일 수 없습니다."); + } +} diff --git a/frontend/src/App.css b/frontend/src/App.css index 495aabeef..69be8de20 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -6,6 +6,9 @@ This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL */ +:root { + --vh: 100%; +} @font-face { font-family: 'Pretendard'; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 921601fa7..7a3e4228c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,10 +19,16 @@ import { getIsMobile } from '~/utils/getIsMobile'; import M_LandingPage from '~/mobilePages/M_LandingPage/M_LandingPage'; import M_TeamSelectPage from '~/mobilePages/M_TeamSelectPage/M_TeamSelectPage'; import M_PageTemplate from '~/mobilePages/M_PageTemplate/M_PageTemplate'; +import { useEffect } from 'react'; const App = () => { const isMobile = getIsMobile(); + useEffect(() => { + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); + }, []); + return ( {isMobile ? ( diff --git a/frontend/src/apis/feed.ts b/frontend/src/apis/feed.ts index 192cffd5b..f21b1db1f 100644 --- a/frontend/src/apis/feed.ts +++ b/frontend/src/apis/feed.ts @@ -2,7 +2,7 @@ import { http } from '~/apis/http'; import { THREAD_SIZE } from '~/constants/feed'; import type { Thread, NoticeThread } from '~/types/feed'; -interface ThreadsResponse { +export interface ThreadsResponse { threads: Thread[]; } diff --git a/frontend/src/apis/schedule.ts b/frontend/src/apis/schedule.ts index c3459e844..122c24046 100644 --- a/frontend/src/apis/schedule.ts +++ b/frontend/src/apis/schedule.ts @@ -19,25 +19,18 @@ interface ICalendarResponse { export const fetchSchedules = ( teamPlaceId: number, - year: number, - month: number, - day?: number, + startDate: string, + endDate: string, ) => { - const query = day - ? `year=${year}&month=${month}&day=${day}` - : `year=${year}&month=${month}`; - return http.get( - `/api/team-place/${teamPlaceId}/calendar/schedules?${query}`, + `/api/team-place/${teamPlaceId}/calendar/schedules?startDate=${startDate}&endDate=${endDate}`, ); }; -export const fetchMySchedules = (year: number, month: number, day?: number) => { - const query = day - ? `year=${year}&month=${month}&day=${day}` - : `year=${year}&month=${month}`; - - return http.get(`/api/my-calendar/schedules?${query}`); +export const fetchMySchedules = (startDate: string, endDate: string) => { + return http.get( + `/api/my-calendar/schedules?startDate=${startDate}&endDate=${endDate}`, + ); }; export const fetchScheduleById = (teamPlaceId: number, scheduleId: number) => { diff --git a/frontend/src/components/common/Header/Header.styled.ts b/frontend/src/components/common/Header/Header.styled.ts index 78c5aff97..93227231c 100644 --- a/frontend/src/components/common/Header/Header.styled.ts +++ b/frontend/src/components/common/Header/Header.styled.ts @@ -14,7 +14,7 @@ export const Header = styled.header<{ $isMobile: boolean }>` ${({ $isMobile }) => $isMobile && css` - height: 110px; + height: 90px; flex-wrap: wrap; flex-direction: row-reverse; `} @@ -62,9 +62,17 @@ export const TeamNameWrapper = styled.div` align-items: center; `; -export const ProfileImage = styled.img` - width: 40px; - height: 40px; +export const ProfileImage = styled.img<{ $isMobile: boolean }>` + ${({ $isMobile }) => + $isMobile + ? css` + width: 30px; + height: 30px; + ` + : css` + width: 40px; + height: 40px; + `} border-radius: 50%; object-fit: cover; @@ -91,14 +99,22 @@ export const notificationButton = css` } `; -export const teamPlaceInfoButton = css` +export const teamPlaceInfoButton = ($isMobile: boolean) => css` display: flex; flex-direction: column; align-items: center; justify-content: center; - width: 44px; - height: 44px; + ${$isMobile + ? css` + width: 30px; + height: 30px; + ` + : css` + width: 44px; + height: 44px; + `} + padding: 0; border-radius: 50%; @@ -108,13 +124,21 @@ export const teamPlaceInfoButton = css` } `; -export const userInfoButton = css` +export const userInfoButton = ($isMobile: boolean) => css` display: flex; align-items: center; justify-content: center; - width: 50px; - height: 50px; + ${$isMobile + ? css` + width: 30px; + height: 30px; + ` + : css` + width: 50px; + height: 50px; + `} + padding: 0; `; diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 384039809..461b66c10 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -171,7 +171,7 @@ const Header = () => { onFocus={prefetchTeamPlaceInfo} onMouseEnter={prefetchTeamPlaceInfo} onClick={handleTeamButtonClick} - css={S.teamPlaceInfoButton} + css={S.teamPlaceInfoButton(isMobile)} aria-label="팀 정보 보기" > @@ -187,11 +187,15 @@ const Header = () => { diff --git a/frontend/src/components/common/NavigationBar/NavigationBar.styled.ts b/frontend/src/components/common/NavigationBar/NavigationBar.styled.ts index 1a5d486c9..58b58bdb1 100644 --- a/frontend/src/components/common/NavigationBar/NavigationBar.styled.ts +++ b/frontend/src/components/common/NavigationBar/NavigationBar.styled.ts @@ -9,7 +9,6 @@ export const Nav = styled.nav<{ $isMobile: boolean }>` return css` width: 100%; height: 60px; - padding: 10px; `; return css` diff --git a/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.stories.tsx b/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.stories.tsx index 655ed8e08..2a4371776 100644 --- a/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.stories.tsx +++ b/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.stories.tsx @@ -33,6 +33,10 @@ const meta = { description: '랜더링할 자식 요소를 의미합니다. `ThumbnailList` 컴포넌트가 여기에 오면 됩니다.', }, + slideDistance: { + description: + '서랍장이 열리게 될 경우 얼마나 많은 거리를 위로 움직여야 할 지를 의미합니다. 입력값은 숫자이며 단위는 `px`입니다.', + }, onClose: { description: '서랍장이 닫히게 될 때 실행시킬 함수를 의미합니다. 서랍장을 실질적으로 닫는 함수를 여기에 넣어 주시면 됩니다.', @@ -47,6 +51,7 @@ type Story = StoryObj; export const Default: Story = { args: { isOpen: false, + slideDistance: 163, children: (

이 자리에 썸네일 리스트 컴포넌트가 올 것입니다. @@ -62,6 +67,23 @@ export const Default: Story = { export const Opened: Story = { args: { isOpen: true, + slideDistance: 163, + children: ( +
+ 이 자리에 썸네일 리스트 컴포넌트가 올 것입니다. +
+ ), + onClose: () => { + alert('onClose();'); + }, + isUploading: false, + }, +}; + +export const CustomDistanceOpened: Story = { + args: { + isOpen: true, + slideDistance: 0, children: (
이 자리에 썸네일 리스트 컴포넌트가 올 것입니다. diff --git a/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.styled.ts b/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.styled.ts index 203250698..90752f099 100644 --- a/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.styled.ts +++ b/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.styled.ts @@ -1,6 +1,10 @@ import { styled, css } from 'styled-components'; -export const Container = styled.div<{ $isOpen: boolean; $isMobile: boolean }>` +export const Container = styled.div<{ + $isOpen: boolean; + $isMobile: boolean; + $slideDistance: number; +}>` display: flex; position: absolute; @@ -26,7 +30,9 @@ export const Container = styled.div<{ $isOpen: boolean; $isMobile: boolean }>` background: linear-gradient(30deg, #bfc3ff, #eaebff); transition: 0.35s; - transform: translateY(${({ $isOpen }) => ($isOpen ? '-163px' : '0')}); + transform: translateY( + ${({ $isOpen, $slideDistance }) => ($isOpen ? `-${$slideDistance}px` : 0)} + ); `; export const ContentWrapper = styled.div` diff --git a/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.tsx b/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.tsx index a9858142a..89d3e066a 100644 --- a/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.tsx +++ b/frontend/src/components/feed/ImageUploadDrawer/ImageUploadDrawer.tsx @@ -6,6 +6,7 @@ import { getIsMobile } from '~/utils/getIsMobile'; interface ImageUploadDrawerProps { isOpen: boolean; + slideDistance: number; onClose: () => void; isUploading: boolean; } @@ -13,11 +14,15 @@ interface ImageUploadDrawerProps { const ImageUploadDrawer = ( props: PropsWithChildren, ) => { - const { isOpen, onClose, children, isUploading } = props; + const { isOpen, onClose, children, isUploading, slideDistance } = props; const isMobile = getIsMobile(); return ( - + {children} {!isUploading && ( diff --git a/frontend/src/components/feed/NoticeThread/NoticeThread.stories.tsx b/frontend/src/components/feed/NoticeThread/NoticeThread.stories.tsx index 6eab472fa..9010b656c 100644 --- a/frontend/src/components/feed/NoticeThread/NoticeThread.stories.tsx +++ b/frontend/src/components/feed/NoticeThread/NoticeThread.stories.tsx @@ -67,3 +67,40 @@ export const TooLongContent: Story = { }, }, }; + +export const ImageContent: Story = { + args: { + authorName: '루루', + createdAt: '2023-12-01 04:12', + content: '중요공지!\n중요공지!\n중요공지!', + images: [ + { + id: 9283, + isExpired: false, + name: 'neon.png', + url: 'https://images.unsplash.com/photo-1508700115892-45ecd05ae2ad?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=2069&q=80', + }, + { + id: 8729, + isExpired: false, + name: 'zXwMd93Xwz2V03M5xAw_fVmxzEwNiDv_93-xVm__902XvC-2XzOqPdR93F3Xz_24RzV01IjSwmOkVeZmIoPlLliFmMVc2__s9Xz.png', + url: 'https://images.unsplash.com/photo-1591382386627-349b692688ff?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1887&q=80', + }, + { + id: 1092, + isExpired: false, + name: 'icon.png', + url: 'https://wrong-link.com/must-show-fallback.png', + }, + { + id: 3493, + isExpired: true, + name: '만료된 사진', + url: '', + }, + ], + onClickImage: () => { + alert('onClickImage'); + }, + }, +}; diff --git a/frontend/src/components/feed/NoticeThread/NoticeThread.styled.ts b/frontend/src/components/feed/NoticeThread/NoticeThread.styled.ts index db9aac880..208d2093f 100644 --- a/frontend/src/components/feed/NoticeThread/NoticeThread.styled.ts +++ b/frontend/src/components/feed/NoticeThread/NoticeThread.styled.ts @@ -5,6 +5,7 @@ import type { NoticeSize } from '~/types/size'; export const Container = styled.div<{ $noticeSize: NoticeSize; $isMobile: boolean; + $hasImage: boolean; }>` position: sticky; top: ${({ $isMobile }) => ($isMobile ? '-4px' : 0)}; @@ -19,11 +20,15 @@ export const Container = styled.div<{ transition: 0.3s; - ${({ $noticeSize }) => { + ${({ $noticeSize, $hasImage }) => { if ($noticeSize === 'sm') return css` height: 80px; `; + if ($noticeSize === 'md' && $hasImage) + return css` + height: 200px; + `; if ($noticeSize === 'md') return css` height: 140px; @@ -110,7 +115,9 @@ export const AuthorInfo = styled.div` height: 16px; `; -export const ContentContainer = styled.div<{ $noticeSize: NoticeSize }>` +export const ContentContainer = styled.div<{ + $noticeSize: NoticeSize; +}>` display: flex; flex-direction: column; justify-content: space-between; @@ -150,11 +157,14 @@ export const timeInfoText = css` color: ${({ theme }) => theme.color.GRAY500}; `; -export const contentField = (noticeSize: NoticeSize) => { +export const contentField = (noticeSize: NoticeSize, hasImage: boolean) => { let height = ''; if (noticeSize === 'sm') height = '24px'; - if (noticeSize === 'md') height = '66px'; + + if (noticeSize === 'md') + if (hasImage) height = '24px'; + else height = '66px'; if (noticeSize === 'lg') height = '100%'; return css` diff --git a/frontend/src/components/feed/NoticeThread/NoticeThread.tsx b/frontend/src/components/feed/NoticeThread/NoticeThread.tsx index 76eb77c8d..be9a4fe70 100644 --- a/frontend/src/components/feed/NoticeThread/NoticeThread.tsx +++ b/frontend/src/components/feed/NoticeThread/NoticeThread.tsx @@ -38,7 +38,11 @@ const NoticeThread = (props: NoticeThreadProps) => { }; return ( - + 0} + > { - + 0)} + > {content} - {images.length > 0 && noticeSize === 'lg' && ( + {images.length > 0 && noticeSize !== 'sm' && ( ($marginBottom ? '40px' : '20px')}; + margin-bottom: ${({ $marginBottom }) => ($marginBottom ? '30px' : '10px')}; background: ${({ theme, $isMe }) => $isMe ? theme.gradient.BLURPLE('116px') : theme.gradient.WHITE('116px')}; @@ -101,7 +101,7 @@ export const contentField = (threadSize: ThreadSize, isMe: boolean) => css` width: 100%; white-space: pre-wrap; - font-size: ${threadSize === 'md' ? 18 : 16}px; + font-size: ${threadSize === 'md' ? 16 : 14}px; color: ${({ theme }) => (isMe ? theme.color.WHITE : theme.color.BLACK)}; word-break: break-all; diff --git a/frontend/src/components/feed/ThreadList/ThreadList.tsx b/frontend/src/components/feed/ThreadList/ThreadList.tsx index 7b33be1d6..e0ffc3cab 100644 --- a/frontend/src/components/feed/ThreadList/ThreadList.tsx +++ b/frontend/src/components/feed/ThreadList/ThreadList.tsx @@ -31,7 +31,6 @@ const ThreadList = (props: ThreadListProps) => { useFetchThreads(teamPlaceId); const threadEndRef = useRef(null); - const threadPagesRef = useRef(0); const observeRef = useRef(null); const [scrollHeight, setScrollHeight] = useState(0); @@ -56,21 +55,27 @@ const ThreadList = (props: ThreadListProps) => { }, [threadPages?.pages.length]); useEffect(() => { - if (threadPages?.pages.length !== threadPagesRef.current) { - threadPagesRef.current = threadPages?.pages.length ?? 0; - } else { - if (!threadEndRef.current) { - return; - } - - if (isShowScrollBottomButton) { - return; - } - - threadEndRef.current.scrollIntoView(); + if (!threadEndRef.current) { + return; } + + threadEndRef.current.scrollIntoView(); + }, [teamPlaceId]); + + useEffect(() => { + console.log(threadPages); + if (!threadEndRef.current) { + return; + } + + if (isShowScrollBottomButton) { + return; + } + + threadEndRef.current.scrollIntoView(); + // eslint-disable-next-line react-hooks/exhaustive-deps - }, [threadPages]); + }, [threadPages?.pages[0].threads.length]); return ( <> diff --git a/frontend/src/components/feed/ThumbnailList/ThumbnailList.styled.ts b/frontend/src/components/feed/ThumbnailList/ThumbnailList.styled.ts index 15c36fa13..b4baf6c81 100644 --- a/frontend/src/components/feed/ThumbnailList/ThumbnailList.styled.ts +++ b/frontend/src/components/feed/ThumbnailList/ThumbnailList.styled.ts @@ -1,23 +1,37 @@ import { styled, css } from 'styled-components'; -export const Container = styled.ul<{ $mode: 'delete' | 'view' }>` +export const Container = styled.ul<{ + $mode: 'delete' | 'view'; + $size: 'md' | 'sm' | undefined; +}>` display: flex; flex-direction: row; flex-shrink: 0; column-gap: 12px; width: 100%; - height: 116px; - ${({ $mode }) => - $mode === 'view' - ? css` + ${({ $mode, $size }) => { + if ($mode === 'view') + if ($size === 'sm') + return css` + overflow-x: auto; + overflow-y: hidden; + `; + else + return css` + height: 116px; + overflow-x: auto; overflow-y: hidden; padding-bottom: 20px; - ` - : css` - overflow-x: visible; - `} + `; + else + return css` + height: 116px; + + overflow-x: visible; + `; + }} `; diff --git a/frontend/src/components/feed/ThumbnailList/ThumbnailList.tsx b/frontend/src/components/feed/ThumbnailList/ThumbnailList.tsx index 7748c60d0..5aef77b0e 100644 --- a/frontend/src/components/feed/ThumbnailList/ThumbnailList.tsx +++ b/frontend/src/components/feed/ThumbnailList/ThumbnailList.tsx @@ -27,7 +27,11 @@ const ThumbnailList = (props: ThumbnailListProps) => { const { mode, images } = props; return ( - + {mode === 'delete' ? images.map((image) => ( ` flex-shrink: 0; - width: ${({ $size = 'md' }) => ($size === 'md' ? '96px' : '76px')}; - height: ${({ $size = 'md' }) => ($size === 'md' ? '96px' : '76px')}; + width: ${({ $size = 'md' }) => ($size === 'md' ? '96px' : '56px')}; + height: ${({ $size = 'md' }) => ($size === 'md' ? '96px' : '56px')}; border-radius: ${({ $size = 'md' }) => ($size === 'md' ? '12px' : '10px')}; `; diff --git a/frontend/src/components/link/LinkTable/LinkTable.styled.ts b/frontend/src/components/link/LinkTable/LinkTable.styled.ts index acc5daac1..dbedcff0a 100644 --- a/frontend/src/components/link/LinkTable/LinkTable.styled.ts +++ b/frontend/src/components/link/LinkTable/LinkTable.styled.ts @@ -38,8 +38,22 @@ export const TableContainer = styled.div<{ box-shadow: 0 10px 20px ${({ theme }) => theme.color.GRAY300}; `; -export const tableProperties = css` - & > th, +export const TableWrapper = styled.div` + overflow-y: auto; + + width: 100%; + height: 100%; + + scrollbar-gutter: stable both-edges; +`; + +export const Table = styled.table` + width: 100%; + + font-size: 18px; + + table-layout: fixed; + & td { display: table-cell; overflow: hidden; @@ -51,63 +65,29 @@ export const tableProperties = css` padding: 8px; } - & > tr { - border-bottom: 2px solid ${({ theme }) => theme.color.GRAY200}; - } - - & > th:first-child(), - & td:first-child() { + & td:first-child(), + thead > tr > th:first-child() { width: 40%; } - & > th:nth-child(2), - & td:nth-child(2) { + & td:nth-child(2), + thead > tr > th:nth-child(2) { width: 20%; } - & > th:nth-child(3), - & td:nth-child(3) { + + & td:nth-child(3), + thead > tr > th:nth-child(3) { width: 30%; } - & > th:nth-child(4), - & td:nth-child(4) { + & td:nth-child(4), + thead > tr > th:nth-child(4) { width: 10%; } - & > tr :not(:first-child), - & th { - text-align: center; - } - - font-size: 18px; - - table-layout: fixed; -`; - -export const TableHeader = styled.table` - width: calc(100% - 32px); - height: 60px; - - ${tableProperties} - - & > th { - font-weight: 600; + tbody > tr { + border-bottom: 2px solid ${({ theme }) => theme.color.GRAY200}; } -`; - -export const TableBody = styled.div` - overflow-y: auto; - - width: 100%; - height: 100%; - - scrollbar-gutter: stable both-edges; -`; - -export const Table = styled.table` - width: 100%; - - ${tableProperties} & td > a { font-weight: 700; @@ -118,6 +98,20 @@ export const Table = styled.table` width: 32px; height: 32px; } + + & td:not(:first-child) { + text-align: center; + } +`; + +export const TableHeader = styled.thead` + width: calc(100% - 32px); + height: 48px; + + tr > th { + vertical-align: middle; + font-weight: 600; + } `; export const linkTableTitle = (linkSize: LinkSize) => css` diff --git a/frontend/src/components/link/LinkTable/LinkTable.tsx b/frontend/src/components/link/LinkTable/LinkTable.tsx index bc6722478..7e9d91b47 100644 --- a/frontend/src/components/link/LinkTable/LinkTable.tsx +++ b/frontend/src/components/link/LinkTable/LinkTable.tsx @@ -64,14 +64,16 @@ const LinkTable = (props: LinkTableProps) => { - - {linkTableHeaderValues.map((value) => ( - {value} - ))} - - {teamLinks.length > 0 ? ( - - + + + + + {linkTableHeaderValues.map((value) => ( + {value} + ))} + + + {teamLinks.map(({ id, title, url, memberName, updatedAt }) => ( @@ -100,11 +102,12 @@ const LinkTable = (props: LinkTableProps) => { ))} - - - ) : ( - - )} + + + {teamLinks.length === 0 && ( + + )} + {isModalOpen && } diff --git a/frontend/src/components/my_calendar/MyCalendar/MyCalendar.tsx b/frontend/src/components/my_calendar/MyCalendar/MyCalendar.tsx index 683921430..1ead0a87c 100644 --- a/frontend/src/components/my_calendar/MyCalendar/MyCalendar.tsx +++ b/frontend/src/components/my_calendar/MyCalendar/MyCalendar.tsx @@ -13,6 +13,7 @@ import TeamBadge from '~/components/team/TeamBadge/TeamBadge'; import { getInfoByTeamPlaceId } from '~/utils/getInfoByTeamPlaceId'; import { useTeamPlace } from '~/hooks/useTeamPlace'; import { usePrefetchMySchedules } from '~/hooks/queries/usePrefetchMySchedules'; +import { generateCalendarRangeByYearMonth } from '~/utils/generateCalendarRangeByYearMonth'; interface MyCalendarProps { onDailyClick: (date: Date) => void; @@ -28,15 +29,22 @@ const MyCalendar = (props: MyCalendarProps) => { today, handlers: { handlePrevButtonClick, handleNextButtonClick }, } = useCalendar(); - const schedules = useFetchMySchedules(year, month); + const { teamPlaces } = useTeamPlace(); + + const prevCalendarYear = month === 0 ? year - 1 : year; + const prevCalendarMonth = month === 0 ? 11 : month - 1; + const nextCalendarYear = month === 11 ? year + 1 : year; + const nextCalendarMonth = month === 11 ? 0 : month + 1; + + const schedules = useFetchMySchedules( + generateCalendarRangeByYearMonth(year, month), + ); usePrefetchMySchedules( - month === 11 ? year + 1 : year, - month === 11 ? 0 : month + 1, + generateCalendarRangeByYearMonth(prevCalendarYear, prevCalendarMonth), ); usePrefetchMySchedules( - month === 0 ? year - 1 : year, - month === 0 ? 11 : month - 1, + generateCalendarRangeByYearMonth(nextCalendarYear, nextCalendarMonth), ); const scheduleCircles = generateScheduleCirclesMatrix(year, month, schedules); diff --git a/frontend/src/components/my_calendar/MyDailyScheduleList/MyDailyScheduleList.tsx b/frontend/src/components/my_calendar/MyDailyScheduleList/MyDailyScheduleList.tsx index 5a4e22a7b..b20c9bb0f 100644 --- a/frontend/src/components/my_calendar/MyDailyScheduleList/MyDailyScheduleList.tsx +++ b/frontend/src/components/my_calendar/MyDailyScheduleList/MyDailyScheduleList.tsx @@ -4,6 +4,7 @@ import * as S from './MyDailyScheduleList.styled'; import { getInfoByTeamPlaceId } from '~/utils/getInfoByTeamPlaceId'; import { useTeamPlace } from '~/hooks/useTeamPlace'; import MyDailySchedule from '~/components/my_calendar/MyDailySchedule/MyDailySchedule'; +import { generateYYYYMMDDWithoutHyphens } from '~/utils/generateYYYYMMDDWithoutHyphens'; interface MyDailyScheduleListProps { rawDate: Date; @@ -12,7 +13,8 @@ interface MyDailyScheduleListProps { const MyDailyScheduleList = (props: MyDailyScheduleListProps) => { const { rawDate } = props; const { year, month, date } = parseDate(rawDate); - const schedules = useFetchMyDailySchedules(year, month, date); + const scheduleDate = generateYYYYMMDDWithoutHyphens(rawDate); + const schedules = useFetchMyDailySchedules(scheduleDate); const { teamPlaces } = useTeamPlace(); return ( diff --git a/frontend/src/components/team_calendar/CalendarDragScreen/CalendarDragScreen.styled.ts b/frontend/src/components/team_calendar/CalendarDragScreen/CalendarDragScreen.styled.ts new file mode 100644 index 000000000..9b746248b --- /dev/null +++ b/frontend/src/components/team_calendar/CalendarDragScreen/CalendarDragScreen.styled.ts @@ -0,0 +1,16 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div<{ $isDragging: boolean }>` + ${({ $isDragging }) => !$isDragging && 'display: none'}; + position: absolute; + overflow: hidden; + left: 0; + top: 0; + + width: 100%; + height: 100%; + + background-color: ${({ theme }) => theme.color.WHITE_BLUR}; + + cursor: all-scroll; +`; diff --git a/frontend/src/components/team_calendar/CalendarDragScreen/CalendarDragScreen.tsx b/frontend/src/components/team_calendar/CalendarDragScreen/CalendarDragScreen.tsx new file mode 100644 index 000000000..500e6153b --- /dev/null +++ b/frontend/src/components/team_calendar/CalendarDragScreen/CalendarDragScreen.tsx @@ -0,0 +1,51 @@ +import * as S from './CalendarDragScreen.styled'; +import { useRef } from 'react'; +import FakeScheduleBarsScreen from '~/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen'; +import type { YYYYMMDDHHMM, DragStatus } from '~/types/schedule'; +import type { CalendarSize } from '~/types/size'; +import { useCalendarDragScreen } from '~/hooks/schedule/useCalendarDragScreen'; + +interface CalendarDragScreenProps { + calendarSize: CalendarSize; + year: number; + month: number; + dragStatus: DragStatus; + onMouseUp: ( + title: string, + startDateTime: YYYYMMDDHHMM, + endDateTime: YYYYMMDDHHMM, + shouldUpdate: boolean, + ) => void; +} + +const CalendarDragScreen = (props: CalendarDragScreenProps) => { + const { calendarSize, year, month, dragStatus, onMouseUp } = props; + const { isDragging, level, schedule, initX, initY } = dragStatus; + const calendarRef = useRef(null); + const { scheduleBars, relativeX, relativeY } = useCalendarDragScreen({ + isDragging, + initX, + initY, + calendarRef, + calendarSize, + onMouseUp, + year, + month, + level, + schedule, + }); + + return ( + + + + + ); +}; + +export default CalendarDragScreen; diff --git a/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.stories.tsx b/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.stories.tsx new file mode 100644 index 000000000..66a9c0bc4 --- /dev/null +++ b/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.stories.tsx @@ -0,0 +1,127 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import type { ComponentType } from 'react'; +import FakeScheduleBarsScreen from '~/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen'; +import type { GeneratedScheduleBar } from '~/types/schedule'; + +/** + * `FakeScheduleBarsScreen` 는 캘린더 바의 드래그 기능을 구현하기 위해 사용자에게 보여주는 가짜 캘린더 바로 구성된, 시각적인 컴포넌트입니다. + * + * `mode = schedule`일 경우, 마우스 조작을 통해 x, y 값을 계속해서 업데이트하면 마우스를 따라다니듯이 작동하도록 만들 수 있습니다. x, y 값을 변경하면서 컴포넌트의 변화를 테스트하세요. + */ +const meta = { + title: 'Schedule/FakeScheduleBarsScreen', + component: FakeScheduleBarsScreen, + tags: ['autodocs'], + decorators: [ + (Story: ComponentType) => ( +
+ +
+ ), + ], + argTypes: { + mode: { + description: + '이 컴포넌트의 모드를 의미합니다. 사용 목적에 따라 `schedule`과 `indicator` 중 하나를 명시해 주세요.', + }, + scheduleBars: { + description: '렌더링할 스케줄 바들의 정보를 의미합니다.', + }, + relativeX: { + description: + '기존 좌표에서 좌우로 얼마나 이동한 위치에 렌더링 시킬 것인지를 의미합니다. 이 값이 양수이면 기존 좌표에서 수치만큼 오른쪽으로 이동하여 렌더링되고, 음수일 경우 왼쪽으로 이동하여 렌더링됩니다. 단위는 픽셀(px)입니다. **이 프로퍼티는 `mode = schedule`일 때만 사용할 수 있습니다.**', + }, + relativeY: { + description: + '기존 좌표에서 상하로 얼마나 이동한 위치에 렌더링 시킬 것인지를 의미합니다. 이 값이 양수이면 기존 좌표에서 수치만큼 아래쪽으로 이동하여 렌더링되고, 음수일 경우 위쪽으로 이동하여 렌더링됩니다. 단위는 픽셀(px)입니다. **이 프로퍼티는 `mode = schedule`일 때만 사용할 수 있습니다.**', + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const scheduleBars: GeneratedScheduleBar[] = [ + { + id: '1', + scheduleId: 1105, + title: '바쁜 필립의 3주짜리 일정', + row: 0, + column: 1, + duration: 6, + level: 0, + roundedStart: true, + roundedEnd: false, + schedule: { + id: 1105, + title: '바쁜 필립의 3주짜리 일정', + startDateTime: '2023-06-26 00:00', + endDateTime: '2023-07-12 23:59', + }, + }, + { + id: '2', + scheduleId: 1105, + title: '바쁜 필립의 3주짜리 일정', + row: 1, + column: 0, + duration: 7, + level: 0, + roundedStart: false, + roundedEnd: false, + schedule: { + id: 1105, + title: '바쁜 필립의 3주짜리 일정', + startDateTime: '2023-06-26 00:00', + endDateTime: '2023-07-12 23:59', + }, + }, + { + id: '3', + scheduleId: 1105, + title: '바쁜 필립의 3주짜리 일정', + row: 2, + column: 0, + duration: 4, + level: 0, + roundedStart: false, + roundedEnd: true, + schedule: { + id: 1105, + title: '바쁜 필립의 3주짜리 일정', + startDateTime: '2023-06-26 00:00', + endDateTime: '2023-07-12 23:59', + }, + }, +]; + +/** + * 이 모드는 가짜 스케줄 바를 보여줘야 할 경우에 사용합니다. + */ +export const ScheduleMode: Story = { + args: { + mode: 'schedule', + scheduleBars, + relativeX: 0, + relativeY: 0, + }, +}; + +/** + * 이 모드는 스케줄 바가 놓일 위치를 시각적으로 보여줘야 할 경우에 사용합니다. + */ +export const IndicatorMode: Story = { + args: { + mode: 'indicator', + scheduleBars, + }, +}; diff --git a/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.styled.ts b/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.styled.ts new file mode 100644 index 000000000..b8e16388a --- /dev/null +++ b/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.styled.ts @@ -0,0 +1,22 @@ +import { styled } from 'styled-components'; + +export const Container = styled.div.attrs<{ + $relativeX: number; + $relativeY: number; +}>(({ $relativeX, $relativeY }) => ({ + style: { + transform: `translate(${$relativeX}px, ${$relativeY}px)`, + }, +}))` + display: flex; + flex-direction: column; + position: absolute; + + width: 100%; + height: 100%; +`; + +export const CalendarRow = styled.div` + position: relative; + flex-grow: 1; +`; diff --git a/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.tsx b/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.tsx new file mode 100644 index 000000000..ab67b64bc --- /dev/null +++ b/frontend/src/components/team_calendar/FakeScheduleBarsScreen/FakeScheduleBarsScreen.tsx @@ -0,0 +1,45 @@ +import * as S from './FakeScheduleBarsScreen.styled'; +import ScheduleBar from '~/components/team_calendar/ScheduleBar/ScheduleBar'; +import { arrayOf } from '~/utils/arrayOf'; +import type { GeneratedScheduleBar } from '~/types/schedule'; + +interface ScheduleModeProps { + mode: 'schedule'; + scheduleBars: GeneratedScheduleBar[]; + relativeX: number; + relativeY: number; +} + +interface IndicatorModeProps { + mode: 'indicator'; + scheduleBars: GeneratedScheduleBar[]; +} + +type FakeScheduleBarsScreenProps = ScheduleModeProps | IndicatorModeProps; + +const FakeScheduleBarsScreen = (props: FakeScheduleBarsScreenProps) => { + const { mode, scheduleBars } = props; + + return ( + + {arrayOf(6).map((_, rowIndex) => ( + + {scheduleBars.map((scheduleBar) => { + return scheduleBar.row === rowIndex ? ( + + ) : null; + })} + + ))} + + ); +}; + +export default FakeScheduleBarsScreen; diff --git a/frontend/src/components/team_calendar/ScheduleAddModal/ScheduleAddModal.tsx b/frontend/src/components/team_calendar/ScheduleAddModal/ScheduleAddModal.tsx index 33bbb368b..fcc46f808 100644 --- a/frontend/src/components/team_calendar/ScheduleAddModal/ScheduleAddModal.tsx +++ b/frontend/src/components/team_calendar/ScheduleAddModal/ScheduleAddModal.tsx @@ -2,9 +2,9 @@ import * as S from './ScheduleAddModal.styled'; import { useModal } from '~/hooks/useModal'; import { CloseIcon } from '~/assets/svg'; import Modal from '~/components/common/Modal/Modal'; -import Text from '../../common/Text/Text'; -import Button from '../../common/Button/Button'; -import Input from '../../common/Input/Input'; +import Text from '~/components/common/Text/Text'; +import Button from '~/components/common/Button/Button'; +import Input from '~/components/common/Input/Input'; import { useScheduleAddModal } from '~/hooks/schedule/useScheduleAddModal'; import Checkbox from '~/components/common/Checkbox/Checkbox'; import TeamBadge from '~/components/team/TeamBadge/TeamBadge'; @@ -30,6 +30,7 @@ const ScheduleAddModal = (props: ScheduleAddModalProps) => { times, handlers: { handleScheduleChange, + handleScheduleBlur, handleIsAllDayChange, handleStartTimeChange, handleEndTimeChange, @@ -67,7 +68,7 @@ const ScheduleAddModal = (props: ScheduleAddModalProps) => { css={S.title} name="title" maxLength={250} - value={schedule['title']} + value={schedule.title} ref={titleInputRef} required onChange={handleScheduleChange} @@ -84,15 +85,16 @@ const ScheduleAddModal = (props: ScheduleAddModalProps) => { height="40px" type="date" css={S.dateTimeLocalInput} - name="startDateTime" - value={schedule['startDateTime']} + name="startDate" + value={schedule.startDate} onChange={handleScheduleChange} - aria-label={`일정 시작 일자는 ${schedule['startDateTime']} 입니다`} + onBlur={handleScheduleBlur} + aria-label={`일정 시작 일자는 ${schedule.startDate} 입니다`} required /> {!isAllDay && ( )} @@ -108,15 +110,16 @@ const ScheduleAddModal = (props: ScheduleAddModalProps) => { height="40px" type="date" css={S.dateTimeLocalInput} - name="endDateTime" - value={schedule['endDateTime']} - aria-label={`일정 마감 일자는 ${schedule['endDateTime']} 입니다`} + name="endDate" + value={schedule.endDate} + aria-label={`일정 마감 일자는 ${schedule.endDate} 입니다`} onChange={handleScheduleChange} + onBlur={handleScheduleBlur} required /> {!isAllDay && ( )} diff --git a/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.stories.tsx b/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.stories.tsx index bc6912594..7cd85f4b7 100644 --- a/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.stories.tsx +++ b/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.stories.tsx @@ -119,3 +119,51 @@ export const LongTitle: Story = { onClick: () => alert('clicked!'), }, }; + +/** + * `mode` 값이 `no-interaction`일 경우, 해당 캘린더 바는 오로지 장식 용도가 되며 **상호작용이 불가능**하게 됩니다. 가짜 스케줄 바 드래그 화면 등 시각적인 효과를 위해 사용할 수 있습니다. + */ +export const NoInteraction: Story = { + args: { + id: '1', + scheduleId: 1, + schedule: { + id: 1, + title: 'No Interaction', + startDateTime: '2023-07-07 05:00', + endDateTime: '2023-07-09 10:00', + }, + title: 'No Interaction', + row: 1, + column: 2, + duration: 3, + level: 0, + roundedStart: true, + roundedEnd: true, + mode: 'no-interaction', + }, +}; + +/** + * `mode` 값이 `indicator`일 경우, 해당 캘린더 바는 **상호작용이 불가능하고 캘린더 바의 윤곽만 드러내는** 시각적 요소가 됩니다. 캘린더 바가 놓일 위치를 시각적으로 표시하는 데에 사용합니다. + */ +export const Indicator: Story = { + args: { + id: '1', + scheduleId: 1, + schedule: { + id: 1, + title: 'This should not shown', + startDateTime: '2023-07-07 05:00', + endDateTime: '2023-07-09 10:00', + }, + title: 'This should not shown', + row: 1, + column: 2, + duration: 3, + level: 0, + roundedStart: true, + roundedEnd: true, + mode: 'indicator', + }, +}; diff --git a/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.styled.ts b/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.styled.ts index 82885925c..101f68762 100644 --- a/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.styled.ts +++ b/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.styled.ts @@ -9,6 +9,7 @@ interface InnerProps { level: number; roundedStart: boolean; roundedEnd: boolean; + mode: 'normal' | 'no-interaction' | 'indicator'; teamPlaceColor: TeamPlaceColor; } @@ -25,6 +26,7 @@ export const Wrapper = styled.div.withConfig({ 'duration', 'roundedStart', 'roundedEnd', + 'mode', ].includes(prop), })< Pick< @@ -35,6 +37,7 @@ export const Wrapper = styled.div.withConfig({ | 'duration' | 'roundedStart' | 'roundedEnd' + | 'mode' > >` position: absolute; @@ -52,9 +55,7 @@ export const Wrapper = styled.div.withConfig({ }} left: ${({ column }) => (column * 100) / 7}%; - width: ${({ duration }) => (duration * 100) / 7}%; - padding: ${({ roundedStart, roundedEnd }) => `0 ${roundedEnd ? '4px' : 0} 0 ${roundedStart ? '4px' : 0}`}; `; @@ -72,6 +73,7 @@ export const Inner = styled.div.withConfig({ 'roundedStart', 'roundedEnd', 'teamPlaceColor', + 'mode', ].includes(prop), })` display: flex; @@ -81,20 +83,39 @@ export const Inner = styled.div.withConfig({ height: 100%; padding-left: 6px; - background-color: ${({ theme, teamPlaceColor = 0 }) => - theme.teamColor[teamPlaceColor]}; + background-color: ${({ theme, teamPlaceColor = 0, mode }) => + mode === 'indicator' ? 'transparent' : theme.teamColor[teamPlaceColor]}; border-radius: ${({ roundedStart, roundedEnd }) => `${roundedStart ? '4px' : '0'} ${roundedEnd ? '4px 4px' : '0 0'} ${ roundedStart ? '4px' : '0' }`}; - filter: brightness(${({ level }) => 1 + level * 0.4}); + ${({ mode, theme }) => + mode === 'indicator' && + css` + margin-top: -2px; + + border: 2px solid ${theme.color.GRAY400}; + + box-shadow: 0 0 24px ${theme.color.GRAY600}; + box-sizing: content-box; + `}; + + ${({ mode, level }) => + mode !== 'indicator' && + css` + filter: brightness(${1 + level * 0.4}); + `}; - cursor: pointer; + ${({ mode }) => + mode === 'normal' && + css` + cursor: pointer; - &:hover { - opacity: 0.8; - } + &:hover { + opacity: 0.8; + } + `}; `; export const scheduleBarTitle = (calendarSize: CalendarSize) => css` diff --git a/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.tsx b/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.tsx index 943b50ded..3473542a8 100644 --- a/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.tsx +++ b/frontend/src/components/team_calendar/ScheduleBar/ScheduleBar.tsx @@ -3,34 +3,50 @@ import * as S from './ScheduleBar.styled'; import type { GeneratedScheduleBar } from '~/types/schedule'; import { DoubleArrowRightIcon } from '~/assets/svg'; import { useTeamPlace } from '~/hooks/useTeamPlace'; -import type { CalendarSize } from '~/types/size'; +import type { MouseEvent } from 'react'; export interface ScheduleBarProps extends GeneratedScheduleBar { - calendarSize?: CalendarSize; onClick?: () => void; + onDragStart?: (e: MouseEvent) => void; } const ScheduleBar = (props: ScheduleBarProps) => { - const { title, onClick, roundedEnd, calendarSize = 'md', ...rest } = props; + const { + title, + onClick, + roundedEnd, + onDragStart, + mode = 'normal', + calendarSize = 'md', + ...rest + } = props; const { teamPlaceColor } = useTeamPlace(); + const isInteractive = mode === 'normal'; + const isIndicator = mode === 'indicator'; return ( - - {title} - - {!roundedEnd && } + {!isIndicator && ( + + {title} + + )} + {!roundedEnd && !isIndicator && } ); diff --git a/frontend/src/components/team_calendar/ScheduleEditModal/ScheduleEditModal.tsx b/frontend/src/components/team_calendar/ScheduleEditModal/ScheduleEditModal.tsx index b4da33ee5..8071f45ec 100644 --- a/frontend/src/components/team_calendar/ScheduleEditModal/ScheduleEditModal.tsx +++ b/frontend/src/components/team_calendar/ScheduleEditModal/ScheduleEditModal.tsx @@ -32,6 +32,7 @@ const ScheduleEditModal = (props: ScheduleEditModalProps) => { isAllDay, handlers: { handleScheduleChange, + handleScheduleBlur, handleScheduleSubmit, handleStartTimeChange, handleEndTimeChange, @@ -66,7 +67,7 @@ const ScheduleEditModal = (props: ScheduleEditModalProps) => { placeholder="일정 제목" css={S.title} name="title" - value={schedule['title']} + value={schedule.title} required onChange={handleScheduleChange} /> @@ -83,13 +84,14 @@ const ScheduleEditModal = (props: ScheduleEditModalProps) => { type="date" css={S.dateTimeLocalInput} name="startDate" - value={schedule['startDate']} + value={schedule.startDate} onChange={handleScheduleChange} + onBlur={handleScheduleBlur} required /> {!isAllDay && ( )} @@ -106,14 +108,15 @@ const ScheduleEditModal = (props: ScheduleEditModalProps) => { type="date" css={S.dateTimeLocalInput} name="endDate" - value={schedule['endDate']} - min={schedule['startDate']} + value={schedule.endDate} + min={schedule.startDate} onChange={handleScheduleChange} + onBlur={handleScheduleBlur} required /> {!isAllDay && ( )} diff --git a/frontend/src/components/team_calendar/ScheduleModal/ScheduleModal.tsx b/frontend/src/components/team_calendar/ScheduleModal/ScheduleModal.tsx index 9c6994dc7..04487c776 100644 --- a/frontend/src/components/team_calendar/ScheduleModal/ScheduleModal.tsx +++ b/frontend/src/components/team_calendar/ScheduleModal/ScheduleModal.tsx @@ -4,7 +4,6 @@ import { useModal } from '~/hooks/useModal'; import * as S from './ScheduleModal.styled'; import { CloseIcon, DeleteIcon, EditIcon } from '~/assets/svg'; import Button from '~/components/common/Button/Button'; -import { formatDateTime } from '~/utils/formatDateTime'; import type { SchedulePosition } from '~/types/schedule'; import { useFetchScheduleById } from '~/hooks/queries/useFetchScheduleById'; import { useDeleteSchedule } from '~/hooks/queries/useDeleteSchedule'; @@ -13,6 +12,7 @@ import { useToast } from '~/hooks/useToast'; import { useTeamPlace } from '~/hooks/useTeamPlace'; import type { CalendarSize } from '~/types/size'; import { getIsMobile } from '~/utils/getIsMobile'; +import { generateDateTimeRangeDescription } from '~/utils/generateDateTimeRangeDescription'; interface ScheduleModalProps { calendarWidth: number; @@ -111,11 +111,9 @@ const ScheduleModal = (props: ScheduleModalProps) => {
- ~ -
{modal} diff --git a/frontend/src/constants/query.ts b/frontend/src/constants/query.ts index 3c969938b..846aa05fb 100644 --- a/frontend/src/constants/query.ts +++ b/frontend/src/constants/query.ts @@ -11,5 +11,7 @@ export const STALE_TIME = { TEAM_LINKS: 1000 * 60, + TEAM_FEED: 1000 * 60 * 5, + ICALENDAR_URL: Infinity, }; diff --git a/frontend/src/hooks/queries/useFetchMyDailySchedule.ts b/frontend/src/hooks/queries/useFetchMyDailySchedule.ts index e592322cc..54f0d25e8 100644 --- a/frontend/src/hooks/queries/useFetchMyDailySchedule.ts +++ b/frontend/src/hooks/queries/useFetchMyDailySchedule.ts @@ -2,14 +2,10 @@ import { useQuery } from '@tanstack/react-query'; import { fetchMySchedules } from '~/apis/schedule'; import { STALE_TIME } from '~/constants/query'; -export const useFetchMyDailySchedules = ( - year: number, - month: number, - day: number, -) => { +export const useFetchMyDailySchedules = (startDate: string) => { const { data } = useQuery( - ['myDailySchedules', year, month, day], - () => fetchMySchedules(year, month + 1, day), + ['myDailySchedules', startDate], + () => fetchMySchedules(startDate, startDate), { staleTime: STALE_TIME.MY_DAILY_SCHEDULES, }, diff --git a/frontend/src/hooks/queries/useFetchMySchedules.ts b/frontend/src/hooks/queries/useFetchMySchedules.ts index 0cee77716..98b5eea79 100644 --- a/frontend/src/hooks/queries/useFetchMySchedules.ts +++ b/frontend/src/hooks/queries/useFetchMySchedules.ts @@ -1,11 +1,14 @@ import { useQuery } from '@tanstack/react-query'; import { fetchMySchedules } from '~/apis/schedule'; import { STALE_TIME } from '~/constants/query'; +import type { DateRange } from '~/types/schedule'; + +export const useFetchMySchedules = (dateRange: DateRange) => { + const { startDate, endDate } = dateRange; -export const useFetchMySchedules = (year: number, month: number) => { const { data } = useQuery( - ['mySchedules', year, month], - () => fetchMySchedules(year, month + 1), + ['mySchedules', startDate, endDate], + () => fetchMySchedules(startDate, endDate), { staleTime: STALE_TIME.MY_SCHEDULES, }, diff --git a/frontend/src/hooks/queries/useFetchSchedules.ts b/frontend/src/hooks/queries/useFetchSchedules.ts index 1226a5ee6..86fb2d0d5 100644 --- a/frontend/src/hooks/queries/useFetchSchedules.ts +++ b/frontend/src/hooks/queries/useFetchSchedules.ts @@ -1,15 +1,17 @@ import { useQuery } from '@tanstack/react-query'; import { fetchSchedules } from '~/apis/schedule'; import { STALE_TIME } from '~/constants/query'; +import type { DateRange } from '~/types/schedule'; export const useFetchSchedules = ( teamPlaceId: number, - year: number, - month: number, + dateRange: DateRange, ) => { + const { startDate, endDate } = dateRange; + const { data } = useQuery( - ['schedules', teamPlaceId, year, month], - () => fetchSchedules(teamPlaceId, year, month + 1), + ['schedules', teamPlaceId, startDate, endDate], + () => fetchSchedules(teamPlaceId, startDate, endDate), { enabled: teamPlaceId > 0, staleTime: STALE_TIME.SCHEDULES, diff --git a/frontend/src/hooks/queries/useFetchThreads.ts b/frontend/src/hooks/queries/useFetchThreads.ts index 3b0168a1f..9073b8491 100644 --- a/frontend/src/hooks/queries/useFetchThreads.ts +++ b/frontend/src/hooks/queries/useFetchThreads.ts @@ -1,6 +1,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import { fetchThreads } from '~/apis/feed'; import { THREAD_SIZE } from '~/constants/feed'; +import { STALE_TIME } from '~/constants/query'; export const useFetchThreads = (teamPlaceId: number) => { const { @@ -16,6 +17,7 @@ export const useFetchThreads = (teamPlaceId: number) => { if (lastPage.threads.length !== THREAD_SIZE) return undefined; return lastPage.threads[THREAD_SIZE - 1].id; }, + staleTime: STALE_TIME.TEAM_FEED, }, ); diff --git a/frontend/src/hooks/queries/usePrefetchMySchedules.ts b/frontend/src/hooks/queries/usePrefetchMySchedules.ts index c87e1fc67..796d1be61 100644 --- a/frontend/src/hooks/queries/usePrefetchMySchedules.ts +++ b/frontend/src/hooks/queries/usePrefetchMySchedules.ts @@ -1,14 +1,16 @@ import { useQueryClient } from '@tanstack/react-query'; import { fetchMySchedules } from '~/apis/schedule'; import { STALE_TIME } from '~/constants/query'; +import type { DateRange } from '~/types/schedule'; -export const usePrefetchMySchedules = async (year: number, month: number) => { - const queryKey = ['mySchedules', year, month]; +export const usePrefetchMySchedules = async (dateRange: DateRange) => { + const { startDate, endDate } = dateRange; + const queryKey = ['mySchedules', startDate, endDate]; const queryClient = useQueryClient(); await queryClient.prefetchQuery( queryKey, - () => fetchMySchedules(year, month + 1), + () => fetchMySchedules(startDate, endDate), { staleTime: STALE_TIME.MY_SCHEDULES, }, diff --git a/frontend/src/hooks/queries/usePrefetchSchedules.ts b/frontend/src/hooks/queries/usePrefetchSchedules.ts index 009ff8fe1..64a2797fe 100644 --- a/frontend/src/hooks/queries/usePrefetchSchedules.ts +++ b/frontend/src/hooks/queries/usePrefetchSchedules.ts @@ -1,19 +1,20 @@ import { useQueryClient } from '@tanstack/react-query'; import { fetchSchedules } from '~/apis/schedule'; import { STALE_TIME } from '~/constants/query'; +import type { DateRange } from '~/types/schedule'; export const usePrefetchSchedules = async ( teamPlaceId: number, - year: number, - month: number, + dateRange: DateRange, ) => { + const { startDate, endDate } = dateRange; const queryClient = useQueryClient(); const enabled = teamPlaceId > 0; if (enabled) { await queryClient.prefetchQuery( - ['schedules', teamPlaceId, year, month], - () => fetchSchedules(teamPlaceId, year, month + 1), + ['schedules', teamPlaceId, startDate, endDate], + () => fetchSchedules(teamPlaceId, startDate, endDate), { staleTime: STALE_TIME.SCHEDULES, }, diff --git a/frontend/src/hooks/queries/useSSE.ts b/frontend/src/hooks/queries/useSSE.ts index 6416f79a0..e321be869 100644 --- a/frontend/src/hooks/queries/useSSE.ts +++ b/frontend/src/hooks/queries/useSSE.ts @@ -1,9 +1,10 @@ import { useCallback, useEffect } from 'react'; -import { useQueryClient } from '@tanstack/react-query'; +import { type InfiniteData, useQueryClient } from '@tanstack/react-query'; import { baseUrl } from '~/apis/http'; import { EventSourcePolyfill } from 'event-source-polyfill'; import { useToken } from '~/hooks/useToken'; import { useTeamPlace } from '~/hooks/useTeamPlace'; +import { type ThreadsResponse } from '~/apis/feed'; export const useSSE = () => { const queryClient = useQueryClient(); @@ -11,7 +12,6 @@ export const useSSE = () => { const { teamPlaceId } = useTeamPlace(); const connect = useCallback(() => { - console.log(teamPlaceId); if (!teamPlaceId) { return; } @@ -25,10 +25,23 @@ export const useSSE = () => { }, ); + eventSource.addEventListener('connect', () => { + queryClient.invalidateQueries([['threadData', teamPlaceId]]); + }); + eventSource.addEventListener('new_thread', (e) => { - console.log('1 ' + e.data); + const newThread = JSON.parse(e.data); - queryClient.invalidateQueries(['threadData', teamPlaceId]); + queryClient.setQueryData>( + ['threadData', teamPlaceId], + (old) => { + if (old) { + old.pages[0].threads = [newThread, ...old.pages[0].threads]; + + return old; + } + }, + ); }); return () => { diff --git a/frontend/src/hooks/schedule/useCalendarDragScreen.ts b/frontend/src/hooks/schedule/useCalendarDragScreen.ts new file mode 100644 index 000000000..fe98e8005 --- /dev/null +++ b/frontend/src/hooks/schedule/useCalendarDragScreen.ts @@ -0,0 +1,150 @@ +import { useState, useEffect, useCallback } from 'react'; +import { generateScheduleBarsByMousePoint } from '~/utils/generateScheduleBarsByMousePoint'; +import type { RefObject } from 'react'; +import type { Schedule, YYYYMMDDHHMM } from '~/types/schedule'; +import type { CalendarSize } from '~/types/size'; + +interface UseCalendarDragScreenProps { + isDragging: boolean; + calendarRef: RefObject; + calendarSize: CalendarSize; + onMouseUp: ( + title: string, + startDateTime: YYYYMMDDHHMM, + endDateTime: YYYYMMDDHHMM, + shouldUpdate: boolean, + ) => void; + initX: number; + initY: number; + year: number; + month: number; + level: number; + schedule: Schedule | null; +} + +interface CalendarPointInfos { + relativeX: number; + relativeY: number; + calendarWidth: number; + calendarHeight: number; +} + +export const useCalendarDragScreen = (props: UseCalendarDragScreenProps) => { + const { + isDragging, + calendarRef, + calendarSize, + initX, + initY, + onMouseUp, + year, + month, + level, + schedule, + } = props; + const [calendarPointInfos, setCalendarPointInfos] = + useState({ + relativeX: 0, + relativeY: 0, + calendarWidth: 0, + calendarHeight: 0, + }); + const { relativeX, relativeY, calendarWidth, calendarHeight } = + calendarPointInfos; + + const scheduleBarsInfo = + schedule === null + ? null + : generateScheduleBarsByMousePoint({ + schedule, + year, + month, + relativeX, + relativeY, + calendarWidth, + calendarHeight, + level, + calendarSize, + }); + const getProcessedRelativePoint = () => { + const processedRelativeX = + ((relativeX + calendarWidth * (15 / 14)) % (calendarWidth / 7)) - + calendarWidth / 14; + const processedRelativeY = + ((relativeY + calendarHeight * (13 / 12)) % (calendarHeight / 6)) - + calendarHeight / 12; + + return { x: processedRelativeX, y: processedRelativeY }; + }; + + const handleMouseMove = useCallback( + (e: globalThis.MouseEvent) => { + if (!isDragging) { + return; + } + + const { clientX, clientY } = e; + + setCalendarPointInfos((prev) => ({ + ...prev, + relativeX: clientX - initX, + relativeY: clientY - initY, + })); + }, + [initX, initY, isDragging], + ); + + const handleMouseUp = useCallback(() => { + if (!isDragging || !scheduleBarsInfo || !schedule) { + return; + } + + const { title } = schedule; + const { startDateTime, endDateTime } = scheduleBarsInfo; + const shouldUpdate = schedule.startDateTime !== startDateTime; + + onMouseUp(title, startDateTime, endDateTime, shouldUpdate); + + setCalendarPointInfos((prev) => ({ + ...prev, + relativeX: 0, + relativeY: 0, + })); + }, [onMouseUp, schedule, scheduleBarsInfo, isDragging]); + + useEffect(() => { + const calendarElement = calendarRef.current; + + if (!calendarElement) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + const { clientWidth, clientHeight } = calendarElement; + + setCalendarPointInfos((prev) => ({ + ...prev, + calendarWidth: clientWidth, + calendarHeight: clientHeight, + })); + }); + + calendarElement.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + resizeObserver.observe(calendarElement); + + return () => { + calendarElement.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + resizeObserver.disconnect(); + }; + }, [calendarRef, handleMouseMove, handleMouseUp]); + + const processedRelativePoint = getProcessedRelativePoint(); + + return { + scheduleBars: scheduleBarsInfo ? scheduleBarsInfo.scheduleBars : [], + relativeX: processedRelativePoint.x, + relativeY: processedRelativePoint.y, + }; +}; diff --git a/frontend/src/hooks/schedule/useDateTimeRange.ts b/frontend/src/hooks/schedule/useDateTimeRange.ts new file mode 100644 index 000000000..29667f10c --- /dev/null +++ b/frontend/src/hooks/schedule/useDateTimeRange.ts @@ -0,0 +1,201 @@ +import { useState } from 'react'; +import type { ChangeEventHandler, FocusEventHandler } from 'react'; +import { ONE_DAY } from '~/constants/calendar'; +import { generateYYYYMMDD } from '~/utils/generateYYYYMMDD'; +import { generateYYYYMMDDHHMM } from '~/utils/generateYYYYMMDDHHMM'; +import { isYYYYMMDD, isYYYYMMDDHHMM } from '~/types/typeGuard'; +import type { Schedule, YYYYMMDD } from '~/types/schedule'; + +interface DateTimeRange { + title: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + dateDifference: number; + isAllDay: boolean; +} + +const getDateDifference = (beforeDate: YYYYMMDD, afterDate: YYYYMMDD) => { + const dateTimeDifference = + (new Date(afterDate).getTime() - new Date(beforeDate).getTime()) / ONE_DAY; + + return dateTimeDifference; +}; + +const getDateAfterDays = (date: YYYYMMDD, days: number) => { + const afterDate = generateYYYYMMDD( + new Date(new Date(date).getTime() + ONE_DAY * days), + ); + + return afterDate; +}; + +const isDateTimeRangeValid = (dateTimeRange: DateTimeRange) => { + const { startDate, endDate, startTime, endTime } = dateTimeRange; + const startDateTime = `${startDate} ${startTime}`; + const endDateTime = `${endDate} ${endTime}`; + + return ( + isYYYYMMDDHHMM(startDateTime) && + isYYYYMMDDHHMM(endDateTime) && + startDateTime <= endDateTime + ); +}; + +const generateDateTimeRange = ( + dateData: Date | Schedule | undefined, + title: string | undefined, +) => { + if (!dateData) { + return { + title: title ?? '', + startDate: '', + endDate: '', + startTime: '09:00', + endTime: '10:00', + dateDifference: 0, + isAllDay: false, + }; + } + + if (dateData instanceof Date) { + const [initDate] = generateYYYYMMDDHHMM(dateData).split(' '); + + return { + title: title ?? '', + startDate: initDate, + endDate: initDate, + startTime: '09:00', + endTime: '10:00', + dateDifference: 0, + isAllDay: false, + }; + } + + const [startDate, startTime] = dateData.startDateTime.split(' '); + const [endDate, endTime] = dateData.endDateTime.split(' '); + const dateDifference = + isYYYYMMDD(startDate) && isYYYYMMDD(endDate) + ? getDateDifference(startDate, endDate) + : 0; + + return { + title: title ?? '', + startDate, + startTime, + endDate, + endTime, + dateDifference, + isAllDay: endTime === '23:59', + }; +}; + +export const useDateTimeRange = ( + dateData: Date | Schedule | undefined, + initTitle: string | undefined, +) => { + const [dateTimeRange, setDateTimeRange] = useState( + generateDateTimeRange(dateData, initTitle), + ); + const { + title, + startDate, + endDate, + startTime, + endTime, + dateDifference, + isAllDay, + } = dateTimeRange; + + const handleScheduleChange: ChangeEventHandler = (e) => { + const { name, value } = e.target; + + if (!['title', 'startDate', 'endDate'].includes(name)) { + return; + } + + setDateTimeRange((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const handleScheduleBlur: FocusEventHandler = (e) => { + const { name } = e.target; + + if ( + !['startDate', 'endDate'].includes(name) || + !isYYYYMMDD(startDate) || + !isYYYYMMDD(endDate) + ) { + return; + } + + if (name === 'startDate') { + const newEndDate = getDateAfterDays(startDate, dateDifference); + + setDateTimeRange((prev) => ({ + ...prev, + endDate: newEndDate, + })); + + return; + } + + const newDateDifference = getDateDifference(startDate, endDate); + + setDateTimeRange((prev) => ({ + ...prev, + dateDifference: newDateDifference, + })); + }; + + const handleStartTimeChange = (newStartTime: string) => { + const newDateTimeRange = { ...dateTimeRange, startTime: newStartTime }; + + if (startDate < endDate || newStartTime <= endTime) { + setDateTimeRange(() => newDateTimeRange); + return; + } + + setDateTimeRange(() => ({ + ...newDateTimeRange, + endTime: newStartTime, + })); + }; + + const handleEndTimeChange = (newEndTime: string) => { + const newDateTimeRange = { ...dateTimeRange, endTime: newEndTime }; + + if (startDate < endDate || startTime <= newEndTime) { + setDateTimeRange(() => newDateTimeRange); + return; + } + + setDateTimeRange(() => ({ + ...newDateTimeRange, + startTime: newEndTime, + })); + }; + + const handleIsAllDayChange = () => { + setDateTimeRange((prev) => ({ ...prev, isAllDay: !prev.isAllDay })); + }; + + return { + handleScheduleChange, + handleScheduleBlur, + handleStartTimeChange, + handleEndTimeChange, + handleIsAllDayChange, + + title, + startDate, + endDate, + startTime: isAllDay ? '00:00' : startTime, + endTime: isAllDay ? '23:59' : endTime, + isValid: isDateTimeRangeValid(dateTimeRange), + isAllDay, + }; +}; diff --git a/frontend/src/hooks/schedule/useScheduleAddModal.ts b/frontend/src/hooks/schedule/useScheduleAddModal.ts index 389801f16..db3128d1f 100644 --- a/frontend/src/hooks/schedule/useScheduleAddModal.ts +++ b/frontend/src/hooks/schedule/useScheduleAddModal.ts @@ -1,121 +1,57 @@ -import { useState } from 'react'; import { useSendSchedule } from '~/hooks/queries/useSendSchedule'; import { useModal } from '~/hooks/useModal'; import { isYYYYMMDDHHMM } from '~/types/typeGuard'; -import { parseDate } from '~/utils/parseDate'; -import type { ChangeEventHandler, FormEventHandler } from 'react'; +import type { FormEventHandler } from 'react'; import { useToast } from '~/hooks/useToast'; import { useTeamPlace } from '~/hooks/useTeamPlace'; +import { useDateTimeRange } from '~/hooks/schedule/useDateTimeRange'; export const useScheduleAddModal = (clickedDate: Date) => { - const { year, month, date } = parseDate(clickedDate); - const dateString = `${year}-${String(month + 1).padStart(2, '0')}-${String( - date, - ).padStart(2, '0')}`; - const [schedule, setSchedule] = useState({ - title: '', - startDateTime: dateString, - endDateTime: dateString, - }); - const [times, setTimes] = useState({ - startTime: '09:00', - endTime: '10:00', - }); - const [isAllDay, setIsAllDay] = useState(false); + const { + title, + startDate, + endDate, + startTime, + endTime, + isValid, + isAllDay, + handleScheduleChange, + handleScheduleBlur, + handleStartTimeChange, + handleEndTimeChange, + handleIsAllDayChange, + } = useDateTimeRange(clickedDate, ''); const { closeModal } = useModal(); const { showToast } = useToast(); const { teamPlaceId } = useTeamPlace(); const { mutateSendSchedule } = useSendSchedule(teamPlaceId); - const handleScheduleChange: ChangeEventHandler = (e) => { - const { name, value } = e.target; - - setSchedule((prev) => { - return { - ...prev, - [name]: value, - }; - }); - }; - - const handleStartTimeChange = (value: string) => { - if (!isValidEndTime(value, times['endTime'])) { - setTimes((prev) => { - return { - ...prev, - ['startTime']: value, - ['endTime']: value, - }; - }); - return; - } - setTimes((prev) => { - return { - ...prev, - ['startTime']: value, - }; - }); - }; - - const handleEndTimeChange = (value: string) => { - if (!isValidEndTime(times['startTime'], value)) { - setTimes((prev) => { - return { - ...prev, - ['endTime']: prev['startTime'], - }; - }); - return; - } - setTimes((prev) => { - return { - ...prev, - ['endTime']: value, - }; - }); - }; - - const isValidEndTime = (startTime: string, endTime: string) => { - const { startDateTime, endDateTime } = schedule; - const start = new Date(`${startDateTime} ${startTime}`); - const end = new Date(`${endDateTime} ${endTime}`); - - return start < end; - }; - - const handleIsAllDayChange = () => { - setIsAllDay((prev) => !prev); - }; + const schedule = { title, startDate, endDate }; + const times = { startTime, endTime }; const handleScheduleSubmit: FormEventHandler = (e) => { e.preventDefault(); - const { title, startDateTime, endDateTime } = schedule; - const { startTime, endTime } = times; - const formattedStartDateTime = `${startDateTime} ${ - isAllDay ? '00:00' : startTime - }`; - const formattedEndDateTime = `${endDateTime} ${ - isAllDay ? '23:59' : endTime - }`; + const startDateTime = `${startDate} ${startTime}`; + const endDateTime = `${endDate} ${endTime}`; - if ( - !isYYYYMMDDHHMM(formattedStartDateTime) || - !isYYYYMMDDHHMM(formattedEndDateTime) - ) { + if (!isYYYYMMDDHHMM(startDateTime) || !isYYYYMMDDHHMM(endDateTime)) { return; } - if (!isValidEndTime(startTime, endTime) && !isAllDay) { - showToast('error', '마감 시간은 시작 시간 이후여야 합니다.'); + if (!isValid) { + showToast( + 'error', + '날짜/시간 형식이 올바르지 않습니다. 올바르게 입력 후 다시 시도해 주세요.', + ); return; } mutateSendSchedule( { title, - startDateTime: formattedStartDateTime, - endDateTime: formattedEndDateTime, + startDateTime, + endDateTime, }, { onSuccess: () => { @@ -138,6 +74,7 @@ export const useScheduleAddModal = (clickedDate: Date) => { handlers: { handleScheduleChange, + handleScheduleBlur, handleIsAllDayChange, handleStartTimeChange, handleEndTimeChange, diff --git a/frontend/src/hooks/schedule/useScheduleBarDragStatus.ts b/frontend/src/hooks/schedule/useScheduleBarDragStatus.ts new file mode 100644 index 000000000..c9a0558a3 --- /dev/null +++ b/frontend/src/hooks/schedule/useScheduleBarDragStatus.ts @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { useTeamPlace } from '~/hooks/useTeamPlace'; +import { useToast } from '~/hooks/useToast'; +import { useModifySchedule } from '~/hooks/queries/useModifySchedule'; +import type { MouseEvent } from 'react'; +import type { Schedule, YYYYMMDDHHMM, DragStatus } from '~/types/schedule'; + +export const useScheduleDragStatus = () => { + const [dragStatus, setDragStatus] = useState({ + isDragging: false, + level: 0, + schedule: null, + initX: 0, + initY: 0, + }); + const { showToast } = useToast(); + const { teamPlaceId } = useTeamPlace(); + const scheduleId = dragStatus.schedule === null ? 0 : dragStatus.schedule.id; + const { mutateModifySchedule } = useModifySchedule(teamPlaceId, scheduleId); + + const handleDragStart = ( + e: MouseEvent, + level: number, + schedule: Schedule, + ) => { + const { clientX, clientY } = e; + + setDragStatus(() => ({ + isDragging: true, + schedule, + level, + initX: clientX, + initY: clientY, + })); + }; + + const handleMouseUp = ( + title: string, + startDateTime: YYYYMMDDHHMM, + endDateTime: YYYYMMDDHHMM, + shouldUpdate: boolean, + ) => { + if (!dragStatus.isDragging) { + return; + } + + setDragStatus((prev) => ({ + ...prev, + isDragging: false, + })); + + if (!shouldUpdate) { + return; + } + + mutateModifySchedule( + { + title, + startDateTime, + endDateTime, + }, + { + onSuccess: () => { + showToast('success', '일정이 수정되었습니다.'); + + setDragStatus((prev) => ({ + ...prev, + schedule: null, + })); + }, + onError: (error) => { + const response = error as Response; + + if (response.status === 500) + showToast('error', '일정 제목이 최대 글자(250자)를 초과했습니다.'); + }, + }, + ); + }; + + return { dragStatus, handleDragStart, handleMouseUp }; +}; diff --git a/frontend/src/hooks/schedule/useScheduleEditModal.ts b/frontend/src/hooks/schedule/useScheduleEditModal.ts index 6213c231d..c1c74f333 100644 --- a/frontend/src/hooks/schedule/useScheduleEditModal.ts +++ b/frontend/src/hooks/schedule/useScheduleEditModal.ts @@ -1,123 +1,61 @@ -import type { ChangeEventHandler, FormEventHandler } from 'react'; -import { useState } from 'react'; import { useModifySchedule } from '~/hooks/queries/useModifySchedule'; import { useModal } from '~/hooks/useModal'; import { isYYYYMMDDHHMM } from '~/types/typeGuard'; +import type { FormEventHandler } from 'react'; import type { Schedule } from '~/types/schedule'; import { useToast } from '~/hooks/useToast'; import { useTeamPlace } from '~/hooks/useTeamPlace'; +import { useDateTimeRange } from '~/hooks/schedule/useDateTimeRange'; export const useScheduleEditModal = ( scheduleId: Schedule['id'], initialSchedule?: Schedule, ) => { - const { teamPlaceId } = useTeamPlace(); - const [startDate, startTime] = initialSchedule?.startDateTime.split(' ') ?? [ - '', - ]; - const [endDate, endTime] = initialSchedule?.endDateTime.split(' ') ?? ['']; - const [schedule, setSchedule] = useState({ - title: initialSchedule?.title, + const { + title, startDate, endDate, - }); - const [times, setTimes] = useState({ startTime, - endTime: endTime === '23:59' ? '23:30' : endTime, - }); - const [isAllDay, setIsAllDay] = useState(endTime === '23:59'); + endTime, + isValid, + isAllDay, + handleScheduleChange, + handleScheduleBlur, + handleStartTimeChange, + handleEndTimeChange, + handleIsAllDayChange, + } = useDateTimeRange(initialSchedule, initialSchedule?.title); const { closeModal } = useModal(); const { showToast } = useToast(); + const { teamPlaceId } = useTeamPlace(); const { mutateModifySchedule } = useModifySchedule(teamPlaceId, scheduleId); - const handleIsAllDayChange = () => { - setIsAllDay((prev) => !prev); - }; - - const handleScheduleChange: ChangeEventHandler = (e) => { - const { name, value } = e.target; - - setSchedule((prev) => ({ ...prev, [name]: value })); - }; - - const handleStartTimeChange = (value: string) => { - if (!isValidEndTime(value, times['endTime'])) { - setTimes((prev) => { - return { - ...prev, - ['startTime']: value, - ['endTime']: value, - }; - }); - - return; - } - - setTimes((prev) => { - return { - ...prev, - ['startTime']: value, - }; - }); - }; - - const handleEndTimeChange = (value: string) => { - if (!isValidEndTime(times['startTime'], value)) { - setTimes((prev) => { - return { - ...prev, - ['endTime']: prev['startTime'], - }; - }); - - return; - } - - setTimes((prev) => { - return { - ...prev, - ['endTime']: value, - }; - }); - }; - - const isValidEndTime = (startTime: string, endTime: string) => { - const { startDate, endDate } = schedule; - const start = new Date(`${startDate} ${startTime}`); - const end = new Date(`${endDate} ${endTime}`); - - return start < end; - }; + const schedule = { title, startDate, endDate }; + const times = { startTime, endTime }; const handleScheduleSubmit: FormEventHandler = (e) => { e.preventDefault(); - const { title, startDate, endDate } = schedule; - const { startTime, endTime } = times; - if ( - typeof title !== 'string' || - typeof startDate !== 'string' || - typeof endDate !== 'string' - ) - return; + const startDateTime = `${startDate} ${startTime}`; + const endDateTime = `${endDate} ${endTime}`; - const formattedStartDateTime = `${startDate} ${ - isAllDay ? '00:00' : startTime - }`; - const formattedEndDateTime = `${endDate} ${isAllDay ? '23:59' : endTime}`; + if (!isYYYYMMDDHHMM(startDateTime) || !isYYYYMMDDHHMM(endDateTime)) { + return; + } - if ( - !isYYYYMMDDHHMM(formattedStartDateTime) || - !isYYYYMMDDHHMM(formattedEndDateTime) - ) { + if (!isValid) { + showToast( + 'error', + '날짜/시간 형식이 올바르지 않습니다. 올바르게 입력 후 다시 시도해 주세요.', + ); return; } mutateModifySchedule( { title, - startDateTime: formattedStartDateTime, - endDateTime: formattedEndDateTime, + startDateTime, + endDateTime, }, { onSuccess: () => { @@ -140,6 +78,7 @@ export const useScheduleEditModal = ( handlers: { handleScheduleChange, + handleScheduleBlur, handleScheduleSubmit, handleStartTimeChange, handleEndTimeChange, diff --git a/frontend/src/mobilePages/M_PageTemplate/M_PageTemplate.styled.ts b/frontend/src/mobilePages/M_PageTemplate/M_PageTemplate.styled.ts index 2186de1a4..4004eb925 100644 --- a/frontend/src/mobilePages/M_PageTemplate/M_PageTemplate.styled.ts +++ b/frontend/src/mobilePages/M_PageTemplate/M_PageTemplate.styled.ts @@ -1,6 +1,7 @@ import { styled } from 'styled-components'; export const PageContainer = styled.div` + justify-content: space-between; position: absolute; top: 0; left: 0; @@ -13,5 +14,5 @@ export const PageContainer = styled.div` export const PageWrapper = styled.main` flex: 1; - height: calc(100vh - 170px); + height: calc(var(--vh, 1vh) * 100 - 150px); `; diff --git a/frontend/src/mocks/fixtures/schedules.ts b/frontend/src/mocks/fixtures/schedules.ts index abd221e13..df458855b 100644 --- a/frontend/src/mocks/fixtures/schedules.ts +++ b/frontend/src/mocks/fixtures/schedules.ts @@ -73,6 +73,42 @@ export const schedules: Schedule[] = [ startDateTime: '2023-09-30 05:00', endDateTime: '2023-10-02 05:00', }, + { + id: 12, + title: '이전 달 일정', + startDateTime: '2023-11-27 10:00', + endDateTime: '2023-11-30 18:00', + }, + { + id: 13, + title: '이번 달과 이전 달에 겹친 일정', + startDateTime: '2023-11-29 10:00', + endDateTime: '2023-12-01 18:00', + }, + { + id: 14, + title: '이번 달 일정', + startDateTime: '2023-12-12 10:00', + endDateTime: '2023-12-14 18:00', + }, + { + id: 15, + title: '이번 달과 다음 달에 걸친 일정', + startDateTime: '2023-12-29 10:00', + endDateTime: '2024-01-01 18:00', + }, + { + id: 16, + title: '다음 달 일정', + startDateTime: '2024-01-01 10:00', + endDateTime: '2024-01-03 18:00', + }, + { + id: 17, + title: '다음 달 일정 2', + startDateTime: '2024-01-02 10:00', + endDateTime: '2024-01-15 18:00', + }, ]; export const mySchedules: ScheduleWithTeamPlaceId[] = [ diff --git a/frontend/src/mocks/handlers/calendar.ts b/frontend/src/mocks/handlers/calendar.ts index 4c6e1bd05..fd261c8d7 100644 --- a/frontend/src/mocks/handlers/calendar.ts +++ b/frontend/src/mocks/handlers/calendar.ts @@ -4,17 +4,36 @@ import { mySchedules as myScheduleData, } from '~/mocks/fixtures/schedules'; import { teamPlaces } from '~/mocks/fixtures/team'; +import { generateYYYYMMDDWithoutHyphens } from '~/utils/generateYYYYMMDDWithoutHyphens'; let schedules = [...scheduleData]; let mySchedules = [...myScheduleData]; export const calendarHandlers = [ //통합캘린더 일정 기간 조회 - rest.get(`/api/my-calendar/schedules`, (_, res, ctx) => { + rest.get(`/api/my-calendar/schedules`, (req, res, ctx) => { + const startDate = req.url.searchParams.get('startDate'); + const endDate = req.url.searchParams.get('endDate'); + + if (!startDate || !endDate) { + return res(ctx.status(400)); + } + + const searchedMySchedules = mySchedules.filter( + ({ startDateTime, endDateTime }) => { + const isScheduleInRange = + startDate <= + generateYYYYMMDDWithoutHyphens(new Date(startDateTime)) || + endDate >= generateYYYYMMDDWithoutHyphens(new Date(endDateTime)); + + return isScheduleInRange; + }, + ); + return res( ctx.status(200), ctx.json({ - schedules: mySchedules, + schedules: searchedMySchedules, }), ); }), @@ -24,16 +43,34 @@ export const calendarHandlers = [ `/api/team-place/:teamPlaceId/calendar/schedules`, (req, res, ctx) => { const teamPlaceId = Number(req.params.teamPlaceId); + const startDate = req.url.searchParams.get('startDate'); + const endDate = req.url.searchParams.get('endDate'); + const index = teamPlaces.findIndex( (teamPlace) => teamPlace.id === teamPlaceId, ); if (index === -1) return res(ctx.status(403)); + if (!startDate || !endDate) { + return res(ctx.status(400)); + } + + const searchedSchedules = schedules.filter( + ({ startDateTime, endDateTime }) => { + const isScheduleInRange = + startDate <= + generateYYYYMMDDWithoutHyphens(new Date(startDateTime)) || + endDate >= generateYYYYMMDDWithoutHyphens(new Date(endDateTime)); + + return isScheduleInRange; + }, + ); + return res( ctx.status(200), ctx.json({ - schedules, + schedules: searchedSchedules, }), ); }, @@ -94,7 +131,9 @@ export const calendarHandlers = [ rest.patch( `/api/team-place/:teamPlaceId/calendar/schedules/:scheduleId`, async (req, res, ctx) => { + const teamPlaceId = Number(req.params.teamPlaceId); const scheduleId = Number(req.params.scheduleId); + const { title, startDateTime, endDateTime } = await req.json(); const index = schedules.findIndex( (schedule) => schedule.id === scheduleId, @@ -116,7 +155,7 @@ export const calendarHandlers = [ mySchedules[myIndex] = { id: scheduleId, - teamPlaceId: mySchedules[myIndex].teamPlaceId, + teamPlaceId, title, startDateTime, endDateTime, diff --git a/frontend/src/mocks/handlers/link.ts b/frontend/src/mocks/handlers/link.ts index f1d0b6eac..32b1cf778 100644 --- a/frontend/src/mocks/handlers/link.ts +++ b/frontend/src/mocks/handlers/link.ts @@ -36,6 +36,8 @@ export const LinkHandlers = [ if (index === -1) return res(ctx.status(403)); + if (teamPlaceId === 2) + return res(ctx.status(200), ctx.json({ teamLinks: [] })); return res(ctx.status(200), ctx.json({ teamLinks })); }), diff --git a/frontend/src/pages/TeamFeedPage/TeamFeedPage.styled.ts b/frontend/src/pages/TeamFeedPage/TeamFeedPage.styled.ts index c5ede931b..103d4a9cd 100644 --- a/frontend/src/pages/TeamFeedPage/TeamFeedPage.styled.ts +++ b/frontend/src/pages/TeamFeedPage/TeamFeedPage.styled.ts @@ -42,8 +42,8 @@ export const ThreadListWrapper = styled.div` display: flex; flex-direction: column; - row-gap: 24px; - padding: 20px 30px; + row-gap: 16px; + padding: 20px 30px 0; background-color: ${({ theme }) => theme.color.WHITE}; `; @@ -113,6 +113,10 @@ export const scrollBottomButton = css` box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1); `; +export const noPaddingButton = css` + padding: 0; +`; + export const noticeText = css` margin-right: 10px; diff --git a/frontend/src/pages/TeamFeedPage/TeamFeedPage.tsx b/frontend/src/pages/TeamFeedPage/TeamFeedPage.tsx index dce38e14d..789ca35ce 100644 --- a/frontend/src/pages/TeamFeedPage/TeamFeedPage.tsx +++ b/frontend/src/pages/TeamFeedPage/TeamFeedPage.tsx @@ -14,7 +14,6 @@ import * as S from './TeamFeedPage.styled'; import { useModal } from '~/hooks/useModal'; import { useState } from 'react'; import type { ThreadImage } from '~/types/feed'; -import { useTeamPlace } from '~/hooks/useTeamPlace'; import { getIsMobile } from '~/utils/getIsMobile'; interface TeamFeedPageProps { @@ -100,6 +99,7 @@ const TeamFeedPage = (props: TeamFeedPageProps) => { isOpen={isImageDrawerOpen} onClose={handleImageDrawerToggle} isUploading={isSendingImage} + slideDistance={isMobile ? 142 : 162} > { variant="plain" aria-label="이미지 업로드하기" onClick={handleImageDrawerToggle} + css={isMobile && S.noPaddingButton} disabled={isSendingImage} > @@ -147,6 +148,7 @@ const TeamFeedPage = (props: TeamFeedPageProps) => { variant="plain" aria-label="채팅 전송하기" disabled={isSendingImage} + css={isMobile && S.noPaddingButton} > ; +export type YYYYMMDD = `${string}-${string}-${string}`; + export type YYYYMMDDHHMM = `${string}-${string}-${string} ${string}:${string}`; export interface Position { @@ -46,4 +49,19 @@ export interface GeneratedScheduleBar { level: number; roundedStart: boolean; roundedEnd: boolean; + calendarSize?: CalendarSize; + mode?: 'normal' | 'no-interaction' | 'indicator'; +} + +export interface DragStatus { + isDragging: boolean; + level: number; + schedule: Schedule | null; + initX: number; + initY: number; +} + +export interface DateRange { + startDate: string; + endDate: string; } diff --git a/frontend/src/types/typeGuard.ts b/frontend/src/types/typeGuard.ts index 88f955202..b53c557b4 100644 --- a/frontend/src/types/typeGuard.ts +++ b/frontend/src/types/typeGuard.ts @@ -1,4 +1,4 @@ -import type { YYYYMMDDHHMM } from '~/types/schedule'; +import type { YYYYMMDDHHMM, YYYYMMDD } from '~/types/schedule'; export const isYYYYMMDDHHMM = (value: unknown): value is YYYYMMDDHHMM => { if (typeof value !== 'string') { @@ -7,3 +7,11 @@ export const isYYYYMMDDHHMM = (value: unknown): value is YYYYMMDDHHMM => { return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(value); }; + +export const isYYYYMMDD = (value: unknown): value is YYYYMMDD => { + if (typeof value !== 'string') { + return false; + } + + return /^\d{4}-\d{2}-\d{2}$/.test(value); +}; diff --git a/frontend/src/utils/generateCalendarRangeByYearMonth.ts b/frontend/src/utils/generateCalendarRangeByYearMonth.ts new file mode 100644 index 000000000..d0440ee10 --- /dev/null +++ b/frontend/src/utils/generateCalendarRangeByYearMonth.ts @@ -0,0 +1,23 @@ +import { CALENDAR, ONE_DAY } from '~/constants/calendar'; +import { generateYYYYMMDDWithoutHyphens } from '~/utils/generateYYYYMMDDWithoutHyphens'; +import type { DateRange } from '~/types/schedule'; + +export const generateCalendarRangeByYearMonth = ( + year: number, + month: number, +): DateRange => { + const firstDateOfMonth = new Date(year, month); + const firstDateOfCalendar = new Date( + firstDateOfMonth.getTime() - ONE_DAY * firstDateOfMonth.getDay(), + ); + const lastDateOfCalendar = new Date( + firstDateOfCalendar.getTime() + + CALENDAR.ROW_SIZE * CALENDAR.COLUMN_SIZE * ONE_DAY - + 1, + ); + + const startDate = generateYYYYMMDDWithoutHyphens(firstDateOfCalendar); + const endDate = generateYYYYMMDDWithoutHyphens(lastDateOfCalendar); + + return { startDate, endDate }; +}; diff --git a/frontend/src/utils/generateDateTimeRangeDescription.ts b/frontend/src/utils/generateDateTimeRangeDescription.ts new file mode 100644 index 000000000..84faf9121 --- /dev/null +++ b/frontend/src/utils/generateDateTimeRangeDescription.ts @@ -0,0 +1,34 @@ +import { formatDateTime } from '~/utils/formatDateTime'; +import type { YYYYMMDDHHMM } from '~/types/schedule'; + +export const generateDateTimeRangeDescription = ( + startDateTime: YYYYMMDDHHMM, + endDateTime: YYYYMMDDHHMM, +) => { + const [startDate] = startDateTime.split(' '); + const [endDate, endTime] = endDateTime.split(' '); + const formattedStartDateTime = formatDateTime(startDateTime); + const formattedEndDateTime = formatDateTime(endDateTime); + const formattedStartDate = formattedStartDateTime + .split(' ') + .slice(0, 3) + .join(' '); + const formattedEndDate = formattedEndDateTime + .split(' ') + .slice(0, 3) + .join(' '); + + if (startDateTime === endDateTime) { + return formattedStartDateTime; + } + + if (endTime === '23:59') { + if (startDate === endDate) { + return formattedStartDate; + } + + return `${formattedStartDate} ~ ${formattedEndDate}`; + } + + return `${formattedStartDateTime} ~ ${formattedEndDateTime}`; +}; diff --git a/frontend/src/utils/generateScheduleBarsByMousePoint.ts b/frontend/src/utils/generateScheduleBarsByMousePoint.ts new file mode 100644 index 000000000..42b72477c --- /dev/null +++ b/frontend/src/utils/generateScheduleBarsByMousePoint.ts @@ -0,0 +1,132 @@ +import { generateScheduleBars } from '~/utils/generateScheduleBars'; +import { CALENDAR, ONE_DAY } from '~/constants/calendar'; +import type { Schedule, YYYYMMDDHHMM } from '~/types/schedule'; +import type { CalendarSize } from '~/types/size'; + +interface GenerateScheduleBarsByMousePointProps { + schedule: Schedule; + year: number; + month: number; + relativeX: number; + relativeY: number; + calendarWidth: number; + calendarHeight: number; + level: number; + calendarSize: CalendarSize; +} + +/** + * 《generateScheduleBarsByMousePoint》 + * 제공된 마우스 상대좌표를 기반으로 렌더링에 적합한 모양의 스케줄 바와 렌더링에 필요한 부가 정보들을 생성하여 반환합니다. + * + * @typedef {GenerateScheduleBarsByMousePointProps} params + * @property {schedule} schedule - 캘린더 바 생성에 사용할 일정 정보를 의미합니다. + * @property {number} year - 캘린더의 연도를 의미합니다. + * @property {number} month - 캘린더의 달을 의미합니다. 수를 0부터 셈에 주의하세요. + * @property {number} relativeX - 드래그를 시작한 지점을 기준으로 현재 마우스의 상대적인 x좌표를 의미합니다. + * @property {number} relativeY - 드래그를 시작한 지점을 기준으로 현재 마우스의 상대적인 y좌표를 의미합니다. + * @property {number} calendarWidth - 캘린더 컴포넌트의 가로 길이를 의미합니다. + * @property {number} calendarHeight - 캘린더 컴포넌트의 세로 길이를 의미합니다. + * @property {number} level - 생성되는 스케줄 바에 지정되어야 할 레벨을 의미합니다. 레벨이란 여러 스케줄 바가 겹칠 경우 어느 위치에 렌더링되어야 할 지를 결정하는 값으로, 0이 최상단이고 값이 오를수록 아래에 배치됩니다. + * @property {CalendarSize} calendarSize - 이 함수를 사용하는 캘린더의 크기를 의미합니다. 캘린더의 크기에 따라 생성되는 스케줄 바의 크기도 달라집니다. + * + * @returns {Object} + * @property {GeneratedScheduleBar[]} scheduleBars - 생성된 스케줄 바들을 의미합니다. + * @property {YYYYMMDDHHMM} startDateTime - 상대좌표를 고려하여 새롭게 반영된 시작 날짜를 의미합니다. + * @property {YYYYMMDDHHMM} endDateTime - 상대좌표를 고려하여 새롭게 반영된 끝 날짜를 의미합니다. + */ +export const generateScheduleBarsByMousePoint = ( + params: GenerateScheduleBarsByMousePointProps, +) => { + const { + schedule, + year, + month, + relativeX, + relativeY, + calendarWidth, + calendarHeight, + level, + calendarSize, + } = params; + + const difference = getCalendarDateDifferenceByMousePoint( + relativeX, + relativeY, + calendarWidth, + calendarHeight, + ); + + const { startDateTime, endDateTime } = schedule; + const changedStartDateTime = changeDateTimeByDays(startDateTime, difference); + const changedEndDateTime = changeDateTimeByDays(endDateTime, difference); + const generatedScheduleBars = generateScheduleBars(year, month, [ + { + ...schedule, + startDateTime: changedStartDateTime, + endDateTime: changedEndDateTime, + }, + ]).map((scheduleBar) => ({ + ...scheduleBar, + level, + calendarSize, + })); + + return { + scheduleBars: generatedScheduleBars, + startDateTime: changedStartDateTime, + endDateTime: changedEndDateTime, + }; +}; + +/** + * 《getCalendarDateDifferenceByMousePoint》 + * 제공된 마우스 상대좌표를 기반으로 올바른 모양의 캘린더 바를 보여주려면 날짜가 얼마나 바뀌어야 하는지를 계산하여 반환합니다. + * + * @param {number} relativeX - 드래그를 시작한 지점을 기준으로 현재 마우스의 상대적인 x좌표를 의미합니다. + * @param {number} relativeY - 드래그를 시작한 지점을 기준으로 현재 마우스의 상대적인 y좌표를 의미합니다. + * @param {number} calendarWidth - 캘린더 컴포넌트의 가로 길이를 의미합니다. + * @param {number} calendarHeight - 캘린더 컴포넌트의 세로 길이를 의미합니다. + * + * @returns {number} calculatedDifference - 변경되어야 하는 날짜의 일 수를 정수 형태로 변환한 값을 의미합니다. 이 값은 음수일 수 있습니다. + */ +const getCalendarDateDifferenceByMousePoint = ( + relativeX: number, + relativeY: number, + calendarWidth: number, + calendarHeight: number, +) => { + const rowDifference = Math.round( + (relativeY * CALENDAR.ROW_SIZE) / calendarHeight, + ); + const columnDifference = Math.round( + (relativeX * CALENDAR.COLUMN_SIZE) / calendarWidth, + ); + const calculatedDifference = + rowDifference * CALENDAR.COLUMN_SIZE + columnDifference; + + return calculatedDifference; +}; + +/** + * 《changeDateTimeByDays》 + * YYYY-MM-DD 형식의 날짜와 함께 변경되어야 하는 날의 수가 주어지면, 이를 반영하여 똑같이 YYYY-MM-DD 형식으로 변경된 날짜를 반환합니다. + * + * @param {YYYYMMDDHHMM} dateTime - 변경을 진행할 YYYY-MM-DD 형식의 날짜 정보입니다. + * @param {number} days - 입력으로 들어가는 날짜 정보의 날짜를 얼마나 변경할 것인지를 의미합니다. 이 값은 정수여야 합니다. + * + * @returns {YYYYMMDDHHMM} changedDateTime - 변경이 반영된 YYYY-MM-DD 형식의 날짜 정보입니다. + */ +const changeDateTimeByDays = (dateTime: YYYYMMDDHHMM, days: number) => { + const changedDate = new Date(Number(new Date(dateTime)) + ONE_DAY * days); + + const year = String(changedDate.getFullYear()).padStart(4, '0'); + const month = String(changedDate.getMonth() + 1).padStart(2, '0'); + const day = String(changedDate.getDate()).padStart(2, '0'); + const time = dateTime.split(' ')[1]; + const [minute, second] = time.split(':'); + + const changedDateTime: YYYYMMDDHHMM = `${year}-${month}-${day} ${minute}:${second}`; + + return changedDateTime; +}; diff --git a/frontend/src/utils/generateYYYYMMDD.ts b/frontend/src/utils/generateYYYYMMDD.ts new file mode 100644 index 000000000..1dc1c2c24 --- /dev/null +++ b/frontend/src/utils/generateYYYYMMDD.ts @@ -0,0 +1,9 @@ +import type { YYYYMMDD } from '~/types/schedule'; + +export const generateYYYYMMDD = (date: Date): YYYYMMDD => { + const year = String(date.getFullYear()).padStart(4, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; +}; diff --git a/frontend/src/utils/generateYYYYMMDDHHMM.ts b/frontend/src/utils/generateYYYYMMDDHHMM.ts new file mode 100644 index 000000000..908847ba8 --- /dev/null +++ b/frontend/src/utils/generateYYYYMMDDHHMM.ts @@ -0,0 +1,11 @@ +import type { YYYYMMDDHHMM } from '~/types/schedule'; + +export const generateYYYYMMDDHHMM = (date: Date): YYYYMMDDHHMM => { + const year = String(date.getFullYear()).padStart(4, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hour = String(date.getHours()).padStart(2, '0'); + const minute = String(date.getMinutes()).padStart(2, '0'); + + return `${year}-${month}-${day} ${hour}:${minute}`; +}; diff --git a/frontend/src/utils/generateYYYYMMDDWithoutHyphens.ts b/frontend/src/utils/generateYYYYMMDDWithoutHyphens.ts new file mode 100644 index 000000000..abf511745 --- /dev/null +++ b/frontend/src/utils/generateYYYYMMDDWithoutHyphens.ts @@ -0,0 +1,7 @@ +export const generateYYYYMMDDWithoutHyphens = (date: Date) => { + const year = String(date.getFullYear()).padStart(4, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}${month}${day}`; +}; diff --git a/frontend/src/utils/test/generateCalendarRangeByYearMonth.test.ts b/frontend/src/utils/test/generateCalendarRangeByYearMonth.test.ts new file mode 100644 index 000000000..35e81f89e --- /dev/null +++ b/frontend/src/utils/test/generateCalendarRangeByYearMonth.test.ts @@ -0,0 +1,17 @@ +import { generateCalendarRangeByYearMonth } from '~/utils/generateCalendarRangeByYearMonth'; +describe('캘린더 범위 생성 테스트', () => { + it.each([ + [2023, 0, { startDate: '20230101', endDate: '20230211' }], + [2023, 1, { startDate: '20230129', endDate: '20230311' }], + [2023, 4, { startDate: '20230430', endDate: '20230610' }], + [2023, 6, { startDate: '20230625', endDate: '20230805' }], + [2023, 11, { startDate: '20231126', endDate: '20240106' }], + [2018, 8, { startDate: '20180826', endDate: '20181006' }], + [2025, 3, { startDate: '20250330', endDate: '20250510' }], + ])( + '%s년 %s월 달력(월은 0-based)의 경우 결괏값은 %s 여야 한다.', + (year, month, expected) => { + expect(generateCalendarRangeByYearMonth(year, month)).toEqual(expected); + }, + ); +}); diff --git a/frontend/src/utils/test/generateDateTimeRangeDescription.test.ts b/frontend/src/utils/test/generateDateTimeRangeDescription.test.ts new file mode 100644 index 000000000..f01dc24d1 --- /dev/null +++ b/frontend/src/utils/test/generateDateTimeRangeDescription.test.ts @@ -0,0 +1,47 @@ +import { generateDateTimeRangeDescription } from '../generateDateTimeRangeDescription'; + +describe('Test #1 - 동일한 일정 테스트', () => { + it('시작 일정과 끝 일정이 완전히 동일할 경우 시작 일정만을 반환해야 한다.', () => { + const startDateTime = '2023-10-21 03:00'; + const endDateTime = '2023-10-21 03:00'; + const expected = '2023년 10월 21일 03:00'; + + expect(generateDateTimeRangeDescription(startDateTime, endDateTime)).toBe( + expected, + ); + }); +}); + +describe('Test #2 - 종일 일정 테스트', () => { + it('하루짜리 종일 일정일 경우, 시간 표시 없이 일정만을 표시해야 한다..', () => { + const startDateTime = '1972-11-21 00:00'; + const endDateTime = '1972-11-21 23:59'; + const expected = '1972년 11월 21일'; + + expect(generateDateTimeRangeDescription(startDateTime, endDateTime)).toBe( + expected, + ); + }); + + it('여러 날에 걸친 종일 일정일 경우, 시작 일정과 끝 일정을 표시하되 시간은 생략해야 한다.', () => { + const startDateTime = '1972-11-21 00:00'; + const endDateTime = '1972-11-24 23:59'; + const expected = '1972년 11월 21일 ~ 1972년 11월 24일'; + + expect(generateDateTimeRangeDescription(startDateTime, endDateTime)).toBe( + expected, + ); + }); +}); + +describe('Test #3 - 일반 일정 테스트', () => { + it('위 테스트에 속하는 일정이 아닌 평범한 일정일 경우, 시작 일정과 끝 일정을 시간을 포함하여 모두 표시하여야 한다.', () => { + const startDateTime = '2023-10-21 03:00'; + const endDateTime = '2023-10-27 18:30'; + const expected = '2023년 10월 21일 03:00 ~ 2023년 10월 27일 18:30'; + + expect(generateDateTimeRangeDescription(startDateTime, endDateTime)).toBe( + expected, + ); + }); +}); diff --git a/frontend/src/utils/test/generateScheduleBars.test.ts b/frontend/src/utils/test/generateScheduleBars.test.ts index f34288505..530a27bd1 100644 --- a/frontend/src/utils/test/generateScheduleBars.test.ts +++ b/frontend/src/utils/test/generateScheduleBars.test.ts @@ -1,4 +1,4 @@ -import { generateScheduleBars } from '../generateScheduleBars'; +import { generateScheduleBars } from '~/utils/generateScheduleBars'; import type { GeneratedScheduleBar, Schedule } from '~/types/schedule'; const removeIdFromScheduleBars = (scheduleBars: GeneratedScheduleBar[]) => { diff --git a/frontend/src/utils/test/generateScheduleBarsByMousePoint.test.ts b/frontend/src/utils/test/generateScheduleBarsByMousePoint.test.ts new file mode 100644 index 000000000..49626d509 --- /dev/null +++ b/frontend/src/utils/test/generateScheduleBarsByMousePoint.test.ts @@ -0,0 +1,353 @@ +import { generateScheduleBarsByMousePoint } from '~/utils/generateScheduleBarsByMousePoint'; +import type { + GeneratedScheduleBar, + Schedule, + YYYYMMDDHHMM, +} from '~/types/schedule'; + +const removeIdFromScheduleBars = (scheduleBars: GeneratedScheduleBar[]) => { + /* eslint-disable-next-line */ + const scheduleBarsWithoutId = scheduleBars.map(({ id, ...rest }) => { + return rest; + }); + + return scheduleBarsWithoutId; +}; + +const defaultParams = { + year: 2023, + month: 10, + calendarWidth: 700, + calendarHeight: 600, + level: 0, + calendarSize: 'md' as const, +}; + +type GeneratedScheduleBarWithoutId = Omit; + +interface ResultValue { + startDateTime: YYYYMMDDHHMM; + endDateTime: YYYYMMDDHHMM; + scheduleBars: GeneratedScheduleBarWithoutId[]; +} + +describe('Test #1 - 좌표 대응 테스트', () => { + test('상대 좌표가 우측에 있을 경우, 그에 대응되는 날짜가 이동된 스케줄 바가 반환되어야 한다.', () => { + const schedule: Schedule = { + id: 1, + title: '내 일정', + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + }; + + const params = { + ...defaultParams, + schedule, + relativeX: 155, + relativeY: 0, + }; + + const expectedResult: ResultValue = { + startDateTime: '2023-11-16 00:00', + endDateTime: '2023-11-18 23:59', + scheduleBars: [ + { + scheduleId: 1, + title: '내 일정', + row: 2, + column: 4, + duration: 3, + level: 0, + roundedStart: true, + roundedEnd: true, + schedule: { + id: 1, + title: '내 일정', + startDateTime: '2023-11-16 00:00', + endDateTime: '2023-11-18 23:59', + }, + calendarSize: 'md', + }, + ], + }; + + const { startDateTime, endDateTime, scheduleBars } = + generateScheduleBarsByMousePoint(params); + + expect({ + startDateTime, + endDateTime, + scheduleBars: removeIdFromScheduleBars(scheduleBars), + }).toEqual(expectedResult); + }); + + test('상대 좌표가 좌상단에 있을 경우, 그에 대응되는 날짜가 이동된 스케줄 바가 반환되어야 한다. 또한, 범위 바깥의 스케줄 바는 잘려야 한다.', () => { + const schedule: Schedule = { + id: 1, + title: '내 일정', + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + }; + + const params = { + ...defaultParams, + schedule, + relativeX: -349.9, + relativeY: -150.1, + }; + + const expectedResult: ResultValue = { + startDateTime: '2023-10-28 00:00', + endDateTime: '2023-10-30 23:59', + scheduleBars: [ + { + scheduleId: 1, + title: '내 일정', + row: 0, + column: 0, + duration: 2, + level: 0, + roundedStart: false, + roundedEnd: true, + schedule: { + id: 1, + title: '내 일정', + startDateTime: '2023-10-28 00:00', + endDateTime: '2023-10-30 23:59', + }, + calendarSize: 'md', + }, + ], + }; + + const { startDateTime, endDateTime, scheduleBars } = + generateScheduleBarsByMousePoint(params); + + expect({ + startDateTime, + endDateTime, + scheduleBars: removeIdFromScheduleBars(scheduleBars), + }).toEqual(expectedResult); + }); + + test('상대 좌표가 우하단에 있을 경우, 그에 대응되는 날짜가 이동된 스케줄 바가 반환되어야 한다. 또한, 이동된 일정에 따라 적절하게 스케줄 바의 모양이 바뀌어야 한다.', () => { + const schedule: Schedule = { + id: 1, + title: '빡구현좋아', + startDateTime: '2023-11-14 14:00', + endDateTime: '2023-11-20 16:30', + }; + + const params = { + ...defaultParams, + schedule, + relativeX: 316, + relativeY: 83, + }; + + const expectedResult: ResultValue = { + startDateTime: '2023-11-24 14:00', + endDateTime: '2023-11-30 16:30', + scheduleBars: [ + { + scheduleId: 1, + title: '빡구현좋아', + row: 3, + column: 5, + duration: 2, + level: 0, + roundedStart: true, + roundedEnd: false, + schedule: { + id: 1, + title: '빡구현좋아', + startDateTime: '2023-11-24 14:00', + endDateTime: '2023-11-30 16:30', + }, + calendarSize: 'md', + }, + { + scheduleId: 1, + title: '빡구현좋아', + row: 4, + column: 0, + duration: 5, + level: 0, + roundedStart: false, + roundedEnd: true, + schedule: { + id: 1, + title: '빡구현좋아', + startDateTime: '2023-11-24 14:00', + endDateTime: '2023-11-30 16:30', + }, + calendarSize: 'md', + }, + ], + }; + + const { startDateTime, endDateTime, scheduleBars } = + generateScheduleBarsByMousePoint(params); + + expect({ + startDateTime, + endDateTime, + scheduleBars: removeIdFromScheduleBars(scheduleBars), + }).toEqual(expectedResult); + }); + + test('상대 좌표의 이동거리가 짧아 일정에 변화가 없는 경우, 변화되지 않은 스케줄 바 그대로를 반환해야 한다.', () => { + const schedule: Schedule = { + id: 1, + title: '내 일정', + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + }; + + const params = { + ...defaultParams, + schedule, + relativeX: 0, + relativeY: 49.9999, + }; + + const expectedResult: ResultValue = { + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + scheduleBars: [ + { + scheduleId: 1, + title: '내 일정', + row: 2, + column: 2, + duration: 3, + level: 0, + roundedStart: true, + roundedEnd: true, + schedule: { + id: 1, + title: '내 일정', + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + }, + calendarSize: 'md', + }, + ], + }; + + const { startDateTime, endDateTime, scheduleBars } = + generateScheduleBarsByMousePoint(params); + + expect({ + startDateTime, + endDateTime, + scheduleBars: removeIdFromScheduleBars(scheduleBars), + }).toEqual(expectedResult); + }); +}); + +describe('Test #2 - 캘린더 크기 대응 테스트', () => { + test('캘린더의 크기가 평소와 달라진 경우, 상대 좌표도 다르게 계산하여 반영하여야 한다.', () => { + const schedule: Schedule = { + id: 1, + title: '내 일정', + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + }; + + const params = { + ...defaultParams, + schedule, + calendarWidth: 732, + calendarHeight: 481, + relativeX: 156.8571, + relativeY: -160.3334, + }; + + const expectedResult: ResultValue = { + startDateTime: '2023-11-01 00:00', + endDateTime: '2023-11-03 23:59', + scheduleBars: [ + { + scheduleId: 1, + title: '내 일정', + row: 0, + column: 3, + duration: 3, + level: 0, + roundedStart: true, + roundedEnd: true, + schedule: { + id: 1, + title: '내 일정', + startDateTime: '2023-11-01 00:00', + endDateTime: '2023-11-03 23:59', + }, + calendarSize: 'md', + }, + ], + }; + + const { startDateTime, endDateTime, scheduleBars } = + generateScheduleBarsByMousePoint(params); + + expect({ + startDateTime, + endDateTime, + scheduleBars: removeIdFromScheduleBars(scheduleBars), + }).toEqual(expectedResult); + }); +}); + +describe('Test #3 - 부가 기능 테스트', () => { + test('캘린더 바의 사이즈, 레벨을 별도로 지정한 후 해당 설정으로 반영된 스케줄 바가 반환되어야 한다.', () => { + const schedule: Schedule = { + id: 1, + title: '내 일정', + startDateTime: '2023-11-14 00:00', + endDateTime: '2023-11-16 23:59', + }; + + const params = { + ...defaultParams, + schedule, + relativeX: 23, + relativeY: 81, + calendarSize: 'sm' as const, + level: 2, + }; + + const expectedResult: ResultValue = { + startDateTime: '2023-11-21 00:00', + endDateTime: '2023-11-23 23:59', + scheduleBars: [ + { + scheduleId: 1, + title: '내 일정', + row: 3, + column: 2, + duration: 3, + level: 2, + roundedStart: true, + roundedEnd: true, + schedule: { + id: 1, + title: '내 일정', + startDateTime: '2023-11-21 00:00', + endDateTime: '2023-11-23 23:59', + }, + calendarSize: 'sm', + }, + ], + }; + + const { startDateTime, endDateTime, scheduleBars } = + generateScheduleBarsByMousePoint(params); + + expect({ + startDateTime, + endDateTime, + scheduleBars: removeIdFromScheduleBars(scheduleBars), + }).toEqual(expectedResult); + }); +}); diff --git a/frontend/src/utils/test/generateYYYYMMDDWithoutHyphens.test.ts b/frontend/src/utils/test/generateYYYYMMDDWithoutHyphens.test.ts new file mode 100644 index 000000000..b6d06a296 --- /dev/null +++ b/frontend/src/utils/test/generateYYYYMMDDWithoutHyphens.test.ts @@ -0,0 +1,20 @@ +import { generateYYYYMMDDWithoutHyphens } from '~/utils/generateYYYYMMDDWithoutHyphens'; + +describe('YYYYMMDD 포맷 생성 테스트', () => { + it.each([ + [new Date('2023-12-19'), '20231219'], + [new Date('0072-01-06'), '00720106'], + [new Date('1972-11-21 04:58'), '19721121'], + [new Date('1972-11-21 23:59'), '19721121'], + [new Date('2023-06-13'), '20230613'], + [new Date('2013-12-01'), '20131201'], + [new Date('2020-02-29'), '20200229'], + [new Date('2000-02-29'), '20000229'], + [new Date('1964-12-31'), '19641231'], + ])( + '%s 정보를 지니는 Date 객체에 대해 %s 값이 반환되어야 한다.', + (date, expected) => { + expect(generateYYYYMMDDWithoutHyphens(date)).toBe(expected); + }, + ); +});