Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,11 @@ public StudiesByCategoryResponse studiesByCategory() {
for (Study study : studies) {
long[] pair = counts.computeIfAbsent(study.getCategory(), c -> new long[2]);
pair[1]++; // total
if (study.getStatus() == StudyStatus.ONGOING) {
// 결정 5: OPEN→ONGOING 전환을 당분간 일으키지 않으므로 START_AT 경과로 진행 중을 판단한다.
if (study.getStatus() == StudyStatus.ONGOING
|| (study.getStatus() == StudyStatus.OPEN
&& study.getStartAt() != null
&& !asOf.isBefore(study.getStartAt()))) {
pair[0]++;
}
}
Expand All @@ -245,16 +249,21 @@ public StudiesByCategoryResponse studiesByCategory() {
}

private StudyBoardResponse ongoingBoard(Instant asOf, ZoneId zone, int limit) {
// 결정 5: OPEN→ONGOING 전환을 당분간 일으키지 않으므로 START_AT 경과로 진행 중을 판단한다.
BackofficeStudyListResponse page =
backofficeStudyListService.getStudies(
new BackofficeStudyListFilter(
null,
null,
StudyStatus.ONGOING,
StudyStatus.OPEN,
null,
null,
null,
BackofficeStudySort.TITLE_ASC,
null,
null,
null,
BackofficeStudySort.TITLE_ASC),
true),
0,
limit,
asOf);
Expand Down Expand Up @@ -336,7 +345,8 @@ static DashboardDeadlineBadge deadlineBadge(
}

private ActiveCrewStats activeCrewStats() {
List<StudyParticipant> actives = studyParticipantRepository.findActiveInOngoingStudies();
List<StudyParticipant> actives =
studyParticipantRepository.findActiveInStartedOpenStudies(Instant.now());
Set<String> enrollments =
actives.stream()
.map(p -> p.getAccountId() + ":" + p.getStudyId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ class DashboardSummaryAggregateService {

@Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW)
ParticipationResult participation() {
List<StudyParticipant> actives = studyParticipantRepository.findActiveInOngoingStudies();
List<StudyParticipant> actives =
studyParticipantRepository.findActiveInStartedOpenStudies(Instant.now());
Set<String> enrollments =
actives.stream()
.map(p -> p.getAccountId() + ":" + p.getStudyId())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ public BackofficeStudyListResponse list(
sort,
keyword,
hasApplicationForm,
recruitFilter),
recruitFilter,
null),
offset,
limit);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ public record BackofficeStudyListFilter(
BackofficeStudySort sort,
String keyword,
Boolean hasApplicationForm,
AdminStudyRecruitFilter recruitFilter) {
AdminStudyRecruitFilter recruitFilter,
/** true 이면 START_AT 이 asOf 이하인 행만 — 대시보드 진행 중 보드에서 사용 (결정 5). */
Boolean startedOnly) {

public BackofficeStudyListFilter(
StudyCategory category,
Expand All @@ -36,6 +38,7 @@ public BackofficeStudyListFilter(
sort,
null,
null,
null,
null);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ private static Sql assemble(BackofficeStudyListFilter filter, Instant asOf, bool
conditions.add("(" + HAS_FORM + ") = :hasForm");
params.put("hasForm", filter.hasApplicationForm() ? 1 : 0);
}
if (Boolean.TRUE.equals(filter.startedOnly())) {
conditions.add("s.START_AT IS NOT NULL AND s.START_AT <= :asOf");
params.put("asOf", Timestamp.from(asOf));
}
boolean recruitFilter = filter.recruitStatus() != null || filter.recruitFilter() != null;
if (filter.recruitStatus() != null) {
conditions.add("s.STATUS = 'OPEN'");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,15 @@ void seed() {
insertAccount(CREW_B, "crew-b@example.com", SystemRole.MEMBER, "America/New_York", now);

// 제목을 AAA 로 시작해 보드 미리보기(최대 4건) 정렬에서 항상 앞에 오게 한다
insertStudy(ONGOING_STUDY, "AAA 대시보드 진행중", "ALGORITHM", "ONGOING", now);
insertStudy(OPEN_STUDY, "AAA 대시보드 모집중", "SOFTWARE", "OPEN", now);
// 결정 5: OPEN→ONGOING 전환 없이 START_AT 경과로 진행 중 판정 — OPEN + 과거 START_AT 으로 세팅
insertStudy(
ONGOING_STUDY,
"AAA 대시보드 진행중",
"ALGORITHM",
"OPEN",
Timestamp.from(Instant.now().minus(1, ChronoUnit.DAYS)),
now);
insertStudy(OPEN_STUDY, "AAA 대시보드 모집중", "SOFTWARE", "OPEN", null, now);
insertGroup(GROUP_ID, ONGOING_STUDY, now);
insertParticipant(CREW_A, ONGOING_STUDY, GROUP_ID, ParticipantStatus.ACTIVE, now);
insertParticipant(CREW_B, ONGOING_STUDY, GROUP_ID, ParticipantStatus.ACTIVE, now);
Expand Down Expand Up @@ -140,27 +147,21 @@ void ongoingBoard() {
}

@Test
@DisplayName("성공 - 진행중 보드는 전체 목록의 첫 최대 4건과 같은 DB 정렬을 쓴다")
void ongoingBoardMatchesAdminListOrder() {
@DisplayName("성공 - 진행중 보드는 START_AT 경과 OPEN 스터디만 표시한다 (결정 5)")
void ongoingBoardShowsStartedOpenOnly() {
var board =
rest.exchange(
"/api/admin/stats/study-board?view=ONGOING",
HttpMethod.GET,
auth(ADMIN_ID),
Map.class);
var list =
rest.exchange(
"/api/admin/studies?status=ONGOING&sort=TITLE_ASC&offset=0&limit=20",
HttpMethod.GET,
auth(ADMIN_ID),
Map.class);

assertThat(board.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(list.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(studyIds(board.getBody()))
.isEqualTo(studyIds(list.getBody()).stream().limit(4).toList());
assertThat(((Number) board.getBody().get("total")).longValue())
.isEqualTo(((Number) list.getBody().get("total")).longValue());
List<Long> ids = studyIds(board.getBody());
// ONGOING_STUDY: STATUS=OPEN, START_AT 과거 → 보드에 포함
assertThat(ids).contains(ONGOING_STUDY);
// OPEN_STUDY: STATUS=OPEN, START_AT 없음 → 보드에 미포함
assertThat(ids).doesNotContain(OPEN_STUDY);
}

@Test
Expand Down Expand Up @@ -364,6 +365,16 @@ private void insertAccount(
}

private void insertStudy(Long id, String title, String category, String status, Timestamp now) {
insertStudy(id, title, category, status, null, now);
}

private void insertStudy(
Long id,
String title,
String category,
String status,
Timestamp startAt,
Timestamp now) {
jdbcTemplate.update(
"INSERT INTO STUDY_PROGRAM (ID, TITLE, STUDY_KIND, CREATED_AT, UPDATED_AT)"
+ " VALUES (?, ?, 'STUDY', ?, ?)",
Expand All @@ -373,13 +384,14 @@ private void insertStudy(Long id, String title, String category, String status,
now);
jdbcTemplate.update(
"INSERT INTO STUDY (ID, PROGRAM_ID, TITLE, ONE_LINE_SUMMARY, CATEGORY, STATUS,"
+ " CREATED_AT, UPDATED_AT) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ " START_AT, CREATED_AT, UPDATED_AT) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
id,
id,
title,
"요약",
category,
status,
startAt,
now,
now);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,17 @@ private BackofficeStudyListFilter filter(
BackofficeStudySort sort,
StudyStatus status) {
return new BackofficeStudyListFilter(
StudyCategory.OTHER, null, status, null, null, null, sort, keyword, form, recruit);
StudyCategory.OTHER,
null,
status,
null,
null,
null,
sort,
keyword,
form,
recruit,
null);
}

private List<Long> ids(BackofficeStudyListResponse response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.studyclub.domain.participant;

import jakarta.persistence.LockModeType;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
Expand All @@ -17,13 +18,17 @@ public interface StudyParticipantRepository extends JpaRepository<StudyParticipa
List<StudyParticipant> findByStudyIdAndStatusIn(
Long studyId, Collection<ParticipantStatus> statuses);

/** 진행 중 기수의 ACTIVE 참여 명부. 대시보드 활성 크루·1인당 참여·시간대 분포용 (specs/dashboard/spec.md 2-1). */
/**
* 진행 중 기수(STATUS=OPEN, START_AT 경과)의 ACTIVE 참여 명부. 대시보드 활성 크루·1인당 참여·시간대 분포용
* (specs/dashboard/spec.md 2-1). 결정 5에 따라 OPEN→ONGOING 전환을 당분간 일으키지 않으므로 START_AT 경과로 판단한다.
*/
@Query(
"SELECT p FROM StudyParticipant p, Study s"
+ " WHERE p.studyId = s.id"
+ " AND s.status = com.studyclub.domain.study.StudyStatus.ONGOING"
+ " AND s.status = com.studyclub.domain.study.StudyStatus.OPEN"
+ " AND s.startAt IS NOT NULL AND s.startAt <= :asOf"
+ " AND p.status = com.studyclub.domain.participant.ParticipantStatus.ACTIVE")
List<StudyParticipant> findActiveInOngoingStudies();
List<StudyParticipant> findActiveInStartedOpenStudies(@Param("asOf") Instant asOf);

void deleteByStudyId(Long studyId);

Expand Down
18 changes: 9 additions & 9 deletions specs/dashboard/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
| 프로그램 | 기수의 상위 묶음인 `STUDY_PROGRAM` |
| 반 | 기수 안의 `STUDY_GROUP`. 반을 나눠도 기수 수는 늘지 않는다 |
| 참여 명부 | `STUDY_PARTICIPANT`. `STUDY_ID`로 기수, `STUDY_GROUP_ID`로 반에 연결한다 |
| 진행 중 기수 | 저장된 `STUDY.STATUS`가 `ONGOING`인 기수 |
| 진행 중 기수 | `STATUS = OPEN`이고 `START_AT ≤ asOf`인 기수. 회차 스펙 결정 5에 따라 `OPEN → ONGOING` 전환을 당분간 일으키지 않으므로 `START_AT` 경과로 판정한다 (`Study.phase()` 와 같은 기준). |
| `asOf` | 서버가 정한 응답의 집계 기준 시각 |
| 조회자 시간대 | 갱신 시각·모집 날짜·주간 집계에 적용하는 시간대. 응답의 `timeZone` 값 |

Expand Down Expand Up @@ -147,7 +147,7 @@

신청 시에는 신청서만 저장한다. 캡틴이 반을 배정할 때 참여 명부를 생성한다. 대시보드는 실제 명부를 조회하며 별도 반 배정 필터를 추가하지 않는다.

진행 중 판정에는 저장된 `STUDY.STATUS = ONGOING`을 사용한다. 첫 미팅 등록에 따른 상태 전환은 스터디 도메인에서 처리한다. 시작일 경과로 진행 상태를 추정하지 않는다.
진행 중 판정에는 `STATUS = OPEN`이고 `STUDY.START_AT ≤ asOf`인 기수를 사용한다. 회차 스펙 결정 5에 따라 `OPEN → ONGOING` 전환을 당분간 일으키지 않으므로 시작일 경과로 판정한다 (2026-11-07 변경).

**논의 중 — 활성 크루 카드의 목록 이동**

Expand Down Expand Up @@ -239,12 +239,12 @@

### 3-1. 진행중 스터디

- `STUDY.STATUS = ONGOING`인 모든 기수를 표시한다. 참여자가 없어도 포함한다.
- `STATUS = OPEN`이고 `START_AT ≤ asOf`인 모든 기수를 표시한다. 참여자가 없어도 포함한다.
- 이름만 표시한다. 출석률·회차 수는 추가하지 않는다.
- 제목 오름차순으로 정렬한다. 제목이 같으면 기수 ID 오름차순으로 정렬한다.
- 대상이 없으면 ‘진행중 스터디가 없습니다.’를 표시한다.

같은 시점에 같은 기수가 두 보드에 동시에 포함되지는 않는다. 같은 프로그램의 서로 다른 기수는 각 보드에 표시할 수 있다.
결정 5로 두 보드 모두 `STATUS = OPEN`을 대상으로 하므로, `START_AT`이 경과하면서 아직 모집 중인 기수는 두 보드에 동시에 표시될 수 있다. 같은 프로그램의 서로 다른 기수는 각 보드에 표시할 수 있다.

### 3-2. 모집중 스터디

Expand Down Expand Up @@ -346,7 +346,7 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간
| 항목 | 규칙 |
|---|---|
| 기본 범위 | ‘진행중’. 새로고침하면 기본값 적용 |
| 진행중 | `STUDY.STATUS = ONGOING` |
| 진행중 | `STATUS = OPEN`이고 `START_AT ≤ asOf`. 결정 5에 따라 `OPEN → ONGOING` 전환 없이 시작일 경과로 판정 |
| 누적 | 조회 시점의 `OPEN / ONGOING / ENDED / CLOSED`. `DRAFT` 제외 |
| 집계 단위 | `STUDY.CATEGORY` 하나에 기수당 1개. `STUDY.ID`로 중복 제거 |
| 표시 | 카테고리 이름·개수·가로 막대. 선택 범위에서 0개인 카테고리는 숨김 |
Expand Down Expand Up @@ -634,9 +634,9 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간
| 프론트 주소의 진입 조건 | 목록 API Query |
|---|---|
| 일반 목록 `/studies` | `sort=RECRUITMENT_FIRST` |
| `view=ongoing` | `status=ONGOING&sort=TITLE_ASC` |
| `view=ongoing` | `status=OPEN&sort=TITLE_ASC` (전체 OPEN 목록 — 보드보다 많을 수 있음) |
| `view=recruitment` | `status=OPEN&sort=RECRUITMENT` |
| `view=ongoing&category={code}` | `category={code}&status=ONGOING&visibility=PUBLIC&sort=TITLE_ASC` |
| `view=ongoing&category={code}` | `category={code}&status=OPEN&visibility=PUBLIC&sort=TITLE_ASC` |
| `view=cumulative&category={code}` | `category={code}&visibility=PUBLIC&sort=ID_DESC` |

- 진입 시 나머지 검색·종류·신청 폼 조건을 초기화한다. 적용한 필터를 화면에 선택된 상태로 표시한다.
Expand Down Expand Up @@ -790,7 +790,7 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간
| asOf | Datetime | N | 공통 규칙 |
| categories | Array | N | 카테고리별 집계. 없으면 [] |
| categories[].code | String | N | `STUDY.CATEGORY`의 StudyCategory 코드. 화면 라벨·목록 필터에 사용 |
| categories[].ongoingCount | Long | N | 해당 카테고리에서 상태가 `ONGOING`인 고유 `STUDY.ID` 수 |
| categories[].ongoingCount | Long | N | 해당 카테고리에서 진행 중(`STATUS = OPEN`이고 `START_AT ≤ asOf`)인 고유 `STUDY.ID` 수 |
| categories[].totalCount | Long | N | 해당 카테고리에서 상태가 `OPEN / ONGOING / ENDED / CLOSED`인 고유 `STUDY.ID` 수 |

두 범위 모두 0인 카테고리는 생략할 수 있다. 두 범위 모두 대상이 없으면 `categories=[]`다. 일부 카테고리의 실패를 0으로 바꾸지 않는다. 프론트는 선택 범위에 따라 정렬·막대·숨김을 적용한다.
Expand All @@ -801,7 +801,7 @@ KST·ET·PT는 차트 그룹 코드다. 실제 날짜 계산에는 IANA 시간
|---|---|
| 권한·입력 | 미로그인·계정 없음 401, 일반 회원 403, 잘못된 Query 400 |
| 집계 단위 | 회원은 `ACCOUNT_ID`, 참여는 `(ACCOUNT_ID, STUDY_ID)`, 기수는 `STUDY.ID`로 중복 제거 |
| 진행 상태·명부 | `ONGOING`·실제 `ACTIVE` 명부만 활성 집계. 신청만 한 회원과 시작일만 지난 `OPEN` 기수는 제외 |
| 진행 상태·명부 | `STATUS = OPEN`이고 `START_AT ≤ asOf`인 기수의 실제 `ACTIVE` 명부만 활성 집계. 신청만 한 회원(`STUDY_PARTICIPANT` 없음) 제외. `START_AT` 없는 `OPEN` 기수는 제외 |
| 출석 참여 기간 | `JOINED_AT`·집계 상한 경계 포함. 탈퇴·하차 전 기록 보존. `LEFT_AT` 누락 시 해당 참여자 제외 |
| 출석 점수·빈 값 | 모두 `EXCUSED` 거나 미입력이면 분모 0 → `null`. 결석만 있으면 0% |
| 공통 계산 | 같은 대상·시각이면 기존 출석 계산과 분자·분모 일치. 백분율 변환은 서버에서 한 번만 수행 |
Expand Down
Loading