diff --git a/docs/api-docs.json b/docs/api-docs.json index 6d5dbf7..1d296f5 100644 --- a/docs/api-docs.json +++ b/docs/api-docs.json @@ -70,6 +70,19 @@ } ], "responses": { + "200": { + "description": "조회 성공", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TodoResponse" + } + } + } + } + }, "401": { "description": "인증 실패", "content": { @@ -83,19 +96,6 @@ } } } - }, - "200": { - "description": "조회 성공", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TodoResponse" - } - } - } - } } } }, @@ -115,20 +115,6 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." - } - } - } - }, "409": { "description": "동일 ID 중복", "content": { @@ -157,40 +143,54 @@ } } }, - "400": { - "description": "입력값 검증 실패", + "201": { + "description": "생성 성공", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/TodoResponse" + } + } + } + }, + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INVALID_INPUT_VALUE", - "message": "title: 비어있을 수 없습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "201": { - "description": "생성 성공", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodoResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "title: 비어있을 수 없습니다." } } } }, - "404": { - "description": "카테고리 없음", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -205,19 +205,6 @@ "description": "createdAt 오름차순", "operationId": "findAll_1", "responses": { - "200": { - "description": "조회 성공", - "content": { - "*/*": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CategoryResponse" - } - } - } - } - }, "401": { "description": "인증 실패", "content": { @@ -231,6 +218,19 @@ } } } + }, + "200": { + "description": "조회 성공", + "content": { + "*/*": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CategoryResponse" + } + } + } + } } } }, @@ -249,16 +249,16 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -273,16 +273,16 @@ } } }, - "500": { - "description": "서버 내부 오류", + "409": { + "description": "동일 ID 중복", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_ALREADY_EXISTS", + "message": "동일 ID의 카테고리가 이미 존재합니다." } } } @@ -301,16 +301,16 @@ } } }, - "409": { - "description": "동일 ID 중복", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_ALREADY_EXISTS", - "message": "동일 ID의 카테고리가 이미 존재합니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -371,22 +371,26 @@ } ], "responses": { - "401": { - "description": "인증 필요", + "200": { + "description": "조회 성공", "content": { - "*/*": { + "application/json": { "schema": { "$ref": "#/components/schemas/TimerSessionListResponse" } } } }, - "200": { - "description": "조회 성공", + "401": { + "description": "인증 필요", "content": { - "application/json": { + "*/*": { "schema": { - "$ref": "#/components/schemas/TimerSessionListResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -445,47 +449,16 @@ } } }, - "404": { - "description": "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + "500": { + "description": "서버 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "TODO_NOT_FOUND", - "message": "해당 할 일을 찾을 수 없습니다." - } - } - } - }, - "201": { - "description": "저장 성공", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TimerSessionCreateResponse" - }, - "example": { - "session": { - "id": "sess-uuid", - "todoId": "todo-1", - "todoTitle": "수학", - "startedAt": "2026-05-25T01:00:00Z", - "endedAt": "2026-05-25T02:30:00Z", - "durationMinutes": 90 - }, - "fuelCharged": 90 - } - } - } - }, - "500": { - "description": "서버 오류", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/TimerSessionCreateResponse" + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } @@ -536,6 +509,41 @@ } } } + }, + "201": { + "description": "저장 성공", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimerSessionCreateResponse" + }, + "example": { + "session": { + "id": "sess-uuid", + "todoId": "todo-1", + "todoTitle": "수학", + "startedAt": "2026-05-25T01:00:00Z", + "endedAt": "2026-05-25T02:30:00Z", + "durationMinutes": 90 + }, + "fuelCharged": 90 + } + } + } + }, + "404": { + "description": "연결된 Todo가 본인 소유 아님 / 존재하지 않음", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "TODO_NOT_FOUND", + "message": "해당 할 일을 찾을 수 없습니다." + } + } + } } } } @@ -576,6 +584,25 @@ } } }, + "401": { + "description": "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidToken": { + "description": "InvalidToken", + "value": { + "code": "INVALID_TOKEN", + "message": "인증 정보가 올바르지 않습니다." + } + } + } + } + } + }, "200": { "description": "토큰 재발급 성공. 클라이언트는 두 토큰 모두 교체 저장해야 합니다.", "content": { @@ -615,25 +642,6 @@ } } } - }, - "401": { - "description": "Refresh Token 이 만료되었거나, DB의 저장 해시와 불일치(탈취 의심)이거나, 변조된 경우. 클라이언트는 로그아웃 처리 후 로그인 화면으로 이동해야 합니다.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "InvalidToken": { - "description": "InvalidToken", - "value": { - "code": "INVALID_TOKEN", - "message": "인증 정보가 올바르지 않습니다." - } - } - } - } - } } } } @@ -674,6 +682,9 @@ } } }, + "204": { + "description": "로그아웃 처리 완료. 응답 본문 없음." + }, "400": { "description": "요청 본문 형식 오류 (refreshToken 누락 등).", "content": { @@ -692,9 +703,6 @@ } } } - }, - "204": { - "description": "로그아웃 처리 완료. 응답 본문 없음." } } } @@ -735,34 +743,6 @@ } } }, - "400": { - "description": "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "examples": { - "InvalidInputValue": { - "summary": "필수 필드 누락", - "description": "InvalidInputValue", - "value": { - "code": "INVALID_INPUT_VALUE", - "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." - } - }, - "UnsupportedSocialType": { - "summary": "지원하지 않는 소셜 타입", - "description": "UnsupportedSocialType", - "value": { - "code": "UNSUPPORTED_SOCIAL_TYPE", - "message": "지원하지 않는 소셜 로그인 방식입니다." - } - } - } - } - } - }, "200": { "description": "기존 회원 로그인 성공.", "content": { @@ -831,6 +811,34 @@ } } } + }, + "400": { + "description": "요청 본문 형식 오류 (필수 필드 누락, socialType 이 유효하지 않은 값 등).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "InvalidInputValue": { + "summary": "필수 필드 누락", + "description": "InvalidInputValue", + "value": { + "code": "INVALID_INPUT_VALUE", + "message": "idToken: 소셜 인증 토큰(ID Token)은 필수입니다." + } + }, + "UnsupportedSocialType": { + "summary": "지원하지 않는 소셜 타입", + "description": "UnsupportedSocialType", + "value": { + "code": "UNSUPPORTED_SOCIAL_TYPE", + "message": "지원하지 않는 소셜 로그인 방식입니다." + } + } + } + } + } } } } @@ -853,23 +861,20 @@ } ], "responses": { - "401": { - "description": "인증 실패", + "404": { + "description": "Todo 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "TODO_NOT_FOUND", + "message": "해당 할 일을 찾을 수 없습니다." } } } }, - "204": { - "description": "삭제 성공" - }, "500": { "description": "서버 내부 오류", "content": { @@ -884,16 +889,19 @@ } } }, - "404": { - "description": "Todo 없음", + "204": { + "description": "삭제 성공" + }, + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "TODO_NOT_FOUND", - "message": "해당 할 일을 찾을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -927,40 +935,44 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } }, - "500": { - "description": "서버 내부 오류", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "INVALID_INPUT_VALUE", + "message": "..." } } } }, - "200": { - "description": "수정 성공", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodoResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -979,16 +991,12 @@ } } }, - "400": { - "description": "입력값 검증 실패", + "200": { + "description": "수정 성공", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INVALID_INPUT_VALUE", - "message": "..." + "$ref": "#/components/schemas/TodoResponse" } } } @@ -1014,47 +1022,47 @@ } ], "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } }, - "204": { - "description": "삭제 성공" - }, - "500": { - "description": "서버 내부 오류", + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "404": { - "description": "카테고리 없음", + "204": { + "description": "삭제 성공" + }, + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1087,68 +1095,68 @@ "required": true }, "responses": { - "401": { - "description": "인증 실패", + "500": { + "description": "서버 내부 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } }, - "200": { - "description": "수정 성공", + "400": { + "description": "입력값 검증 실패", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/CategoryResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "..." } } } }, - "500": { - "description": "서버 내부 오류", + "404": { + "description": "카테고리 없음", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "CATEGORY_NOT_FOUND", + "message": "해당 카테고리를 찾을 수 없습니다." } } } }, - "400": { - "description": "입력값 검증 실패", + "401": { + "description": "인증 실패", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "code": "INVALID_INPUT_VALUE", - "message": "..." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "404": { - "description": "카테고리 없음", + "200": { + "description": "수정 성공", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "CATEGORY_NOT_FOUND", - "message": "해당 카테고리를 찾을 수 없습니다." + "$ref": "#/components/schemas/CategoryResponse" } } } @@ -1192,75 +1200,75 @@ } } }, - "200": { - "description": "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", + "409": { + "description": "이미 다른 사용자가 사용 중인 닉네임.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateNicknameResponse" + "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UpdateSuccess": { - "description": "UpdateSuccess", + "DuplicatedNickname": { + "description": "DuplicatedNickname", "value": { - "nickname": "우주탐험가" + "code": "DUPLICATED_NICKNAME", + "message": "이미 사용 중인 닉네임입니다." } } } } } }, - "400": { - "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자).", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "description": "InvalidInputValue", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INVALID_INPUT_VALUE", - "message": "nickname: 닉네임은 2자 이상 10자 이하여야 합니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우.", + "200": { + "description": "닉네임 변경 성공. 응답 본문에 변경된 닉네임 포함.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/UpdateNicknameResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "UpdateSuccess": { + "description": "UpdateSuccess", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "nickname": "우주탐험가" } } } } } }, - "409": { - "description": "이미 다른 사용자가 사용 중인 닉네임.", + "400": { + "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "DuplicatedNickname": { - "description": "DuplicatedNickname", + "InvalidInputValue": { + "description": "InvalidInputValue", "value": { - "code": "DUPLICATED_NICKNAME", - "message": "이미 사용 중인 닉네임입니다." + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 2자 이상 10자 이하여야 합니다." } } } @@ -1273,8 +1281,8 @@ "/api/timer-sessions/today-stats": { "get": { "tags": ["Timer"], - "summary": "오늘 공부 통계", - "description": "KST(Asia/Seoul) 기준 오늘의 총 분 / 세션 수 / 연속 일수(streak)", + "summary": "오늘 공부 통계 + 누적 통계", + "description": "KST(Asia/Seoul) 기준 통계.\n\n### 응답 필드\n- `totalMinutes`, `sessionCount`: 오늘 (KST)\n- `streak`: 연속 공부 일수 (오늘 포함, KST)\n- `lifetimeMinutes`, `lifetimeSessionCount`: 회원의 전체 누적\n- `monthlyMinutes`: 이번 달 누적 (KST 1일 00:00 ~ 다음 달 1일 00:00)\n\n세션 0건 회원도 6개 필드 모두 `0`을 반환합니다 (null 금지).\n", "operationId": "getTodayStats", "responses": { "401": { @@ -1282,7 +1290,11 @@ "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/TodayStatsResponse" + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1297,7 +1309,10 @@ "example": { "totalMinutes": 180, "sessionCount": 3, - "streak": 7 + "streak": 7, + "lifetimeMinutes": 12450, + "lifetimeSessionCount": 287, + "monthlyMinutes": 1820 } } } @@ -1334,26 +1349,26 @@ } } }, - "500": { - "description": "서버 오류", + "401": { + "description": "인증 필요", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } }, - "401": { - "description": "인증 필요", + "500": { + "description": "서버 오류", "content": { "*/*": { "schema": { "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." } } } @@ -1414,40 +1429,6 @@ } ], "responses": { - "401": { - "description": "인증 필요", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/FuelTransactionListResponse" - } - } - } - }, - "500": { - "description": "서버 오류", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/FuelTransactionListResponse" - } - } - } - }, - "400": { - "description": "잘못된 query parameter", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - }, - "example": { - "code": "INVALID_INPUT_VALUE", - "message": "type은 charge 또는 consume이어야 합니다." - } - } - } - }, "200": { "description": "조회 성공", "content": { @@ -1479,6 +1460,48 @@ } } } + }, + "400": { + "description": "잘못된 query parameter", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INVALID_INPUT_VALUE", + "message": "type은 charge 또는 consume이어야 합니다." + } + } + } + }, + "401": { + "description": "인증 필요", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." + } + } + } + }, + "500": { + "description": "서버 오류", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." + } + } + } } } } @@ -1533,38 +1556,38 @@ } } }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + "400": { + "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자 포함 등). `message` 필드에 어떤 필드의 어떤 제약을 어겼는지 상세 표기됩니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "InvalidInputValue": { + "description": "InvalidInputValue", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INVALID_INPUT_VALUE", + "message": "nickname: 닉네임은 한글, 영문, 숫자만 사용할 수 있습니다." } } } } } }, - "400": { - "description": "닉네임 형식 오류 (길이 미달/초과, 허용되지 않은 문자 포함 등). `message` 필드에 어떤 필드의 어떤 제약을 어겼는지 상세 표기됩니다.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InvalidInputValue": { - "description": "InvalidInputValue", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INVALID_INPUT_VALUE", - "message": "nickname: 닉네임은 한글, 영문, 숫자만 사용할 수 있습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -1603,38 +1626,38 @@ "204": { "description": "탈퇴 성공. 응답 본문 없음. (이미 탈퇴된 상태 / Firebase 측 사용자 부재 / 외부 시스템 일시 오류 등 모두 포함)" }, - "401": { - "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", + "500": { + "description": "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "UnauthenticatedRequest": { - "description": "UnauthenticatedRequest", + "InternalServerError": { + "description": "InternalServerError", "value": { - "code": "UNAUTHENTICATED_REQUEST", - "message": "로그인이 필요합니다." + "code": "INTERNAL_SERVER_ERROR", + "message": "서버 내부 오류가 발생했습니다." } } } } } }, - "500": { - "description": "서버 내부 오류. 주로 DB 통신 실패 시. 사용자에게는 \"잠시 후 다시 시도해주세요\" 안내가 적절합니다.", + "401": { + "description": "인증 실패 — Access Token 이 헤더에 없거나, 만료되었거나, 유효하지 않은 경우. 클라이언트는 `/api/auth/reissue` 로 재발급을 시도해야 합니다.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" }, "examples": { - "InternalServerError": { - "description": "InternalServerError", + "UnauthenticatedRequest": { + "description": "UnauthenticatedRequest", "value": { - "code": "INTERNAL_SERVER_ERROR", - "message": "서버 내부 오류가 발생했습니다." + "code": "UNAUTHENTICATED_REQUEST", + "message": "로그인이 필요합니다." } } } @@ -2169,7 +2192,7 @@ }, "TodayStatsResponse": { "type": "object", - "description": "오늘 공부 통계 (KST 기준)", + "description": "오늘 공부 통계 + 누적 통계 (KST 기준)", "properties": { "totalMinutes": { "type": "integer", @@ -2185,6 +2208,21 @@ "type": "integer", "format": "int32", "description": "연속 공부 일수 (오늘 포함, KST 기준)" + }, + "lifetimeMinutes": { + "type": "integer", + "format": "int32", + "description": "회원의 전체 누적 공부 시간 (분)" + }, + "lifetimeSessionCount": { + "type": "integer", + "format": "int32", + "description": "회원의 전체 세션 수" + }, + "monthlyMinutes": { + "type": "integer", + "format": "int32", + "description": "이번 달 누적 공부 시간 (분, KST 기준)" } } }, diff --git a/docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md b/docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md new file mode 100644 index 0000000..22d0fe2 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-today-stats-cumulative-fields-frontend.md @@ -0,0 +1,515 @@ +# today-stats 누적 통계 필드 3개 클라이언트 연동 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 백엔드가 추가한 `lifetimeMinutes`/`lifetimeSessionCount`/`monthlyMinutes` 필드를 클라이언트 DTO/Entity 에 노출하고, `todayStatsNotifierProvider` 가 단일 진실 소스가 되도록 누적 통계 provider 를 위임 패턴으로 정리한다. + +**Architecture:** Entity 가 6필드 `required` 가 되면 `TodayStats(...)` / `TodayStatsResponseModel(...)` 직접 호출 5곳이 동시에 컴파일 에러 → Task 1 에서 일괄 처리해 중간 상태 컴파일을 보존한다. Task 2 에서 `study_stats_provider` 의 3개 함수를 `todayStatsNotifierProvider.valueOrNull?.X ?? 0` 한 줄로 단순화하여 인증/게스트 분기 책임을 한 곳으로 모은다. + +**Tech Stack:** Flutter 3.9, Riverpod 2.6 (riverpod_annotation), Freezed 2.5, build_runner, mocktail, flutter_test + +**Spec:** `docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md` + +--- + +## File Structure + +| 파일 | 작업 | Task | +|---|---|---| +| `lib/features/timer/data/models/today_stats_response_model.dart` | M — 3필드 + `toEntity()` | 1 | +| `lib/features/timer/domain/entities/today_stats.dart` | M — 3필드 + `empty()` | 1 | +| `lib/features/timer/presentation/providers/today_stats_provider.dart` | M — 게스트 분기 6필드 fold | 1 | +| `lib/features/timer/presentation/providers/study_stats_provider.dart` | M — 위임 한 줄로 단순화, `isAuthenticatedProvider` 제거 | 2 | +| `*.freezed.dart`, `*.g.dart` | regenerate via build_runner | 1, 2 | +| `test/features/timer/data/models/today_stats_response_model_test.dart` | M — 6필드 JSON 픽스처 | 1 | +| `test/features/timer/domain/entities/today_stats_test.dart` | M — fixture 2건 6필드 | 1 | +| `test/features/timer/data/repositories/timer_session_repository_impl_test.dart` | M — DTO 픽스처 6필드 | 1 | +| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | M — fixture 6필드 | 1 | +| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | C — 신규, 위임 동작 검증 | 2 | + +--- + +## Task 1: Entity·DTO 6필드 확장 + 모든 호출처 컴파일 에러 해소 + +**Files:** +- Modify: `lib/features/timer/domain/entities/today_stats.dart` (전체 재작성) +- Modify: `lib/features/timer/data/models/today_stats_response_model.dart` (전체 재작성) +- Modify: `lib/features/timer/presentation/providers/today_stats_provider.dart:14-44` (게스트 분기 fold) +- Modify: `test/features/timer/data/models/today_stats_response_model_test.dart` (전체 재작성) +- Modify: `test/features/timer/domain/entities/today_stats_test.dart` (전체 재작성) +- Modify: `test/features/timer/data/repositories/timer_session_repository_impl_test.dart:170-183` (fixture 갱신) +- Modify: `test/features/timer/presentation/providers/today_stats_provider_test.dart:23-37` (fixture 갱신) + +### Step 1: DTO 테스트 갱신 (RED) + +`test/features/timer/data/models/today_stats_response_model_test.dart` 를 다음으로 교체. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/data/models/today_stats_response_model.dart'; + +void main() { + test('fromJson + toEntity — 6필드 모두 매핑', () { + final m = TodayStatsResponseModel.fromJson({ + 'totalMinutes': 180, + 'sessionCount': 3, + 'streak': 7, + 'lifetimeMinutes': 12450, + 'lifetimeSessionCount': 287, + 'monthlyMinutes': 1820, + }); + expect(m.totalMinutes, 180); + expect(m.sessionCount, 3); + expect(m.streak, 7); + expect(m.lifetimeMinutes, 12450); + expect(m.lifetimeSessionCount, 287); + expect(m.monthlyMinutes, 1820); + + final e = m.toEntity(); + expect(e.totalMinutes, 180); + expect(e.sessionCount, 3); + expect(e.streak, 7); + expect(e.lifetimeMinutes, 12450); + expect(e.lifetimeSessionCount, 287); + expect(e.monthlyMinutes, 1820); + }); +} +``` + +### Step 2: Entity 테스트 갱신 (RED) + +`test/features/timer/domain/entities/today_stats_test.dart` 를 다음으로 교체. + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; + +void main() { + test('TodayStats — 6필드 생성', () { + const stats = TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); + expect(stats.totalMinutes, 180); + expect(stats.lifetimeMinutes, 12450); + expect(stats.monthlyMinutes, 1820); + }); + + test('TodayStats.empty(): 6필드 모두 0', () { + expect( + TodayStats.empty(), + const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ), + ); + }); +} +``` + +### Step 3: 회귀 가드 fixture 갱신 — `today_stats_provider_test.dart` + +`test/features/timer/presentation/providers/today_stats_provider_test.dart:23-37` 의 인증 모드 케이스에서 `TodayStats` fixture 를 6필드로 확장. 다음 블록을 찾아 교체. + +```dart + test('인증 모드: repo.getTodayStats() 결과 그대로 반환', () async { + when(() => repo.getTodayStats()).thenAnswer( + (_) async => const TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ), + ); + container = ProviderContainer( + overrides: [ + isAuthenticatedProvider.overrideWith((ref) => true), + timerSessionRepositoryProvider.overrideWith((ref) => repo), + ], + ); + final stats = await container.read(todayStatsNotifierProvider.future); + expect(stats.totalMinutes, 180); + expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); + }); +``` + +게스트 모드 케이스 (line 39-69 부근) 는 `repository.getSessions()` mock 만 사용하므로 fixture 변경 불필요. 단, 마지막 assertion 에 신규 필드 1개만 추가해서 fold 결과 확인: + +```dart + expect(stats.sessionCount, 2); + expect(stats.totalMinutes, 90); + expect(stats.streak, 1); + expect(stats.lifetimeMinutes, 90); // 게스트는 전체 = today 합산 + expect(stats.lifetimeSessionCount, 2); +``` + +### Step 4: 회귀 가드 fixture 갱신 — `timer_session_repository_impl_test.dart` + +`test/features/timer/data/repositories/timer_session_repository_impl_test.dart:170-183` 의 `getTodayStats` 그룹에서 DTO fixture 를 6필드로 확장. + +```dart + group('getTodayStats', () { + test('성공: TodayStats 반환', () async { + when(() => remote.getTodayStats()).thenAnswer( + (_) async => const TodayStatsResponseModel( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ), + ); + final stats = await repo.getTodayStats(); + expect(stats.totalMinutes, 180); + expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); + }); + }); +``` + +### Step 5: Entity 필드 추가 + +`lib/features/timer/domain/entities/today_stats.dart` 를 다음으로 교체. + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'today_stats.freezed.dart'; + +/// KST 기준 오늘 공부 통계 + 누적 통계 (GET /api/timer-sessions/today-stats). +@freezed +class TodayStats with _$TodayStats { + const factory TodayStats({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStats; + + factory TodayStats.empty() => const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ); +} +``` + +### Step 6: DTO 필드 추가 + toEntity 매핑 + +`lib/features/timer/data/models/today_stats_response_model.dart` 를 다음으로 교체. + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../domain/entities/today_stats.dart'; + +part 'today_stats_response_model.freezed.dart'; +part 'today_stats_response_model.g.dart'; + +@freezed +class TodayStatsResponseModel with _$TodayStatsResponseModel { + const TodayStatsResponseModel._(); + + const factory TodayStatsResponseModel({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStatsResponseModel; + + factory TodayStatsResponseModel.fromJson(Map json) => + _$TodayStatsResponseModelFromJson(json); + + TodayStats toEntity() => TodayStats( + totalMinutes: totalMinutes, + sessionCount: sessionCount, + streak: streak, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: lifetimeSessionCount, + monthlyMinutes: monthlyMinutes, + ); +} +``` + +### Step 7: today_stats_provider 게스트 분기 6필드 fold + +`lib/features/timer/presentation/providers/today_stats_provider.dart` 의 `build()` 메서드 전체를 다음으로 교체 (전체 파일 line 14-44 영역). + +```dart + @override + Future build() async { + final isAuthenticated = ref.watch(isAuthenticatedProvider); + final repository = ref.watch(timerSessionRepositoryProvider); + + if (isAuthenticated) { + return repository.getTodayStats(); + } + + // 게스트 모드: 로컬 세션 fold (전체가 메모리에 있어 정확) + final sessions = repository.getSessions(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(const Duration(days: 1)); + final monthStart = DateTime(now.year, now.month, 1); + + final todaySessions = sessions.where((s) { + return s.startedAt.isAfter(today.subtract(const Duration(seconds: 1))) && + s.startedAt.isBefore(tomorrow); + }).toList(); + + final todayMinutes = + todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); + final lifetimeMinutes = + sessions.fold(0, (sum, s) => sum + s.durationMinutes); + final monthlyMinutes = sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); + + return TodayStats( + totalMinutes: todayMinutes, + sessionCount: todaySessions.length, + streak: todaySessions.isEmpty ? 0 : 1, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: sessions.length, + monthlyMinutes: monthlyMinutes, + ); + } +``` + +### Step 8: build_runner 재생성 + +Run: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +Expected: `[INFO] Succeeded` 로 종료. `today_stats.freezed.dart`, `today_stats_response_model.freezed.dart`, `today_stats_response_model.g.dart` 가 6필드 반영하여 재생성됨. + +### Step 9: 분석·테스트 통과 확인 (GREEN) + +Run: +```bash +flutter analyze lib/features/timer test/features/timer +``` +Expected: `No issues found!` + +```bash +flutter test test/features/timer +``` +Expected: 모든 케이스 PASS. 특히: +- `today_stats_response_model_test.dart` — 1 case PASS +- `today_stats_test.dart` — 2 cases PASS +- `today_stats_provider_test.dart` — 2 cases PASS (인증·게스트 분기) +- `timer_session_repository_impl_test.dart` — 기존 케이스 회귀 없음 + +### Step 10: 커밋 + +```bash +git add lib/features/timer/domain/entities/today_stats.dart \ + lib/features/timer/domain/entities/today_stats.freezed.dart \ + lib/features/timer/data/models/today_stats_response_model.dart \ + lib/features/timer/data/models/today_stats_response_model.freezed.dart \ + lib/features/timer/data/models/today_stats_response_model.g.dart \ + lib/features/timer/presentation/providers/today_stats_provider.dart \ + test/features/timer/data/models/today_stats_response_model_test.dart \ + test/features/timer/domain/entities/today_stats_test.dart \ + test/features/timer/data/repositories/timer_session_repository_impl_test.dart \ + test/features/timer/presentation/providers/today_stats_provider_test.dart +git commit -m "feat : TodayStats 누적 통계 3필드 추가 + 게스트 분기 6필드 fold #83" +``` + +--- + +## Task 2: study_stats_provider 위임 단순화 + 신규 테스트 + +**Files:** +- Create: `test/features/timer/presentation/providers/study_stats_provider_test.dart` +- Modify: `lib/features/timer/presentation/providers/study_stats_provider.dart:36-65` (3 함수 단순화) + +### Step 1: 신규 테스트 작성 (RED) + +`test/features/timer/presentation/providers/study_stats_provider_test.dart` 신규 생성: + +```dart +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/study_stats_provider.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/today_stats_provider.dart'; + +void main() { + test('todayStats 로드 완료 → 3 provider 가 lifetime/monthly 값 위임', () { + final stats = const TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); + + final container = ProviderContainer( + overrides: [ + todayStatsNotifierProvider.overrideWith(() { + return _StubTodayStatsNotifier(stats); + }), + ], + ); + addTearDown(container.dispose); + + expect(container.read(totalStudyMinutesProvider), 12450); + expect(container.read(totalSessionCountProvider), 287); + expect(container.read(monthlyStudyMinutesProvider), 1820); + }); +} + +class _StubTodayStatsNotifier extends TodayStatsNotifier { + _StubTodayStatsNotifier(this._stats); + final TodayStats _stats; + + @override + Future build() async => _stats; +} +``` + +### Step 2: 테스트 실패 확인 (RED 검증) + +Run: +```bash +flutter test test/features/timer/presentation/providers/study_stats_provider_test.dart +``` +Expected: FAIL. `totalStudyMinutesProvider` 가 현재 `timerSessionListNotifierProvider` 의존이라 0 반환 → expected 12450 mismatch. + +### Step 3: study_stats_provider 위임 패턴 적용 + +`lib/features/timer/presentation/providers/study_stats_provider.dart` 의 `// === 월별/전체 통계 ===` 섹션(line 36 이후) ~ `// === Streak ===` 직전 까지의 3개 함수 (`monthlyStudyMinutes`, `totalStudyMinutes`, `totalSessionCount`) 를 다음으로 교체. + +```dart +// === 월별/전체 통계 === + +/// 이번 달 공부 시간 (분) — todayStats 위임 +@riverpod +int monthlyStudyMinutes(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; +} + +/// 전체 총 공부 시간 (분) — todayStats 위임 +@riverpod +int totalStudyMinutes(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; +} + +/// 전체 세션 수 — todayStats 위임 +@riverpod +int totalSessionCount(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; +} +``` + +추가로 파일 상단 import 에 `today_stats_provider.dart` 가 없으면 추가: + +```dart +import 'today_stats_provider.dart'; +``` + +`todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak` 는 변경 없음. + +### Step 4: build_runner 재생성 + +Run: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` +Expected: `[INFO] Succeeded`. `study_stats_provider.g.dart` 의존성 해시 갱신. + +### Step 5: 테스트 통과 확인 (GREEN) + +Run: +```bash +flutter test test/features/timer/presentation/providers/study_stats_provider_test.dart +``` +Expected: PASS — 1 case. + +```bash +flutter test test/features/timer +``` +Expected: 전체 PASS (회귀 없음). + +```bash +flutter analyze lib/features/timer test/features/timer +``` +Expected: `No issues found!` + +### Step 6: 커밋 + +```bash +git add lib/features/timer/presentation/providers/study_stats_provider.dart \ + lib/features/timer/presentation/providers/study_stats_provider.g.dart \ + test/features/timer/presentation/providers/study_stats_provider_test.dart +git commit -m "refactor : study_stats_provider 누적 통계 3개 todayStats 위임 단순화 #83" +``` + +--- + +## Final Verification + +### Step 1: 전체 분석 통과 + +Run: +```bash +flutter analyze +``` +Expected: `No issues found!` (프로젝트 전체) + +### Step 2: 전체 테스트 통과 + +Run: +```bash +flutter test +``` +Expected: 전체 PASS, 회귀 없음. + +### Step 3: 호출처 동작 수동 검증 + +Run: +```bash +flutter run +``` +- 인증 사용자로 로그인 → 프로필 화면 진입 → "공부 시간" 카드가 서버 누적값 표시 확인. +- 게스트 모드 → 프로필 화면 진입 → 로컬 세션 합산값 표시 확인 (회귀 없음). + +### Step 4: grep 회귀 가드 + +Run: +```bash +git grep -n 'TodayStats(' -- ':!*.freezed.dart' ':!*.g.dart' +git grep -n 'TodayStatsResponseModel(' -- ':!*.freezed.dart' ':!*.g.dart' +``` +Expected: 모든 호출처가 6필드 또는 `.empty()` 사용. 3필드 잔존 호출 없음. + +--- + +## Plan Self-Review + +- **Spec coverage**: §3 의 10행 파일 변경표 → 모두 Task 1·2 에 매핑됨. §4.1~4.5 의 코드 블록 → Task 1 Step 5~7 + Task 2 Step 3 에 그대로 반영. §6.1~6.3 의 테스트 → Task 1 Step 1·2·3·4 + Task 2 Step 1 에 반영. §6.4 회귀 가드 4개 파일 → Task 1 Step 1·2·3·4 모두 처리. §8 검증 체크리스트 → Final Verification 4 step 에 반영. +- **Placeholder scan**: TBD/TODO/생략 없음. 모든 코드 블록 완전. import 경로 명시. +- **Type consistency**: `TodayStats` 의 필드명 (`lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`) 이 Task 1·2 전체에서 일관. `valueOrNull` 패턴 통일. provider 명 (`totalStudyMinutesProvider`, `totalSessionCountProvider`, `monthlyStudyMinutesProvider`) 일치. diff --git a/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md new file mode 100644 index 0000000..263a301 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-today-stats-cumulative-fields-frontend-design.md @@ -0,0 +1,275 @@ +# today-stats 누적 통계 필드 3개 클라이언트 연동 — Design Spec + +- **Issue**: `issue/today-stats-cumulative-fields-frontend.md` (Frontend #83) +- **Backend pair**: 이미 머지됨. `GET /api/timer-sessions/today-stats` 응답에 누적 필드 3개 추가 완료. `docs/api-docs.json` 의 `TodayStatsResponse` 가 6필드(`totalMinutes`, `sessionCount`, `streak`, `lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`)로 갱신됨. +- **Branch**: `20260526_#83_today_stats_누적_통계_필드_3개_클라이언트_연동` + +--- + +## 1. 문제 정의 + +인증 사용자의 누적 통계(전체 공부 시간 / 전체 세션 수 / 이번 달 공부 시간) 가 클라이언트에서 잘못 계산되고 있다. + +**현재 동작** + +`lib/features/timer/presentation/providers/study_stats_provider.dart` 의 3개 provider — +`totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` — 가 전부 로컬 `timerSessionListNotifierProvider` 의 결과를 fold 해서 계산한다. + +인증 모드에서 `timerSessionListNotifierProvider` 는 서버 페이지네이션(`size=20`) 의 **첫 페이지만** 보유한다. 결과적으로: + +- 프로필 화면 (`profile_screen.dart:83`) 의 "공부 시간" 통계 카드가 최근 20세션만 합산. +- 뱃지 해금 (`badge_provider.dart:53,55`) 의 "총 100시간 공부" / "총 50회 세션 완료" 류 조건이 영구 미해금. + +게스트 모드는 모든 세션이 인메모리(로컬 저장) 에 있어 fold 합산이 정확하다 — 분기 처리 필요. + +--- + +## 2. 솔루션 개요 + +서버가 반환하기 시작한 누적 필드 3개를 클라이언트가 수신·노출한다. `todayStatsNotifierProvider` 가 **단일 진실 소스(single source of truth)** 가 되도록 정리한다. + +- **인증 모드**: 서버 응답을 그대로 신뢰 — 이미 동작 중. +- **게스트 모드**: 기존에는 3필드만 채웠지만, 이제 로컬 세션을 fold 해서 6필드 모두 채운다. 이슈 명시("게스트 모드는 로컬 전체 세션이 메모리에 있어 정확함")와 일치. + +`study_stats_provider.dart` 의 누적 통계 3개 함수는 단순히 `todayStatsNotifierProvider.valueOrNull?.X ?? 0` 한 줄로 위임 — 자체 분기 로직 제거. 더 이상 `isAuthenticatedProvider` 를 watch 하지 않는다. + +--- + +## 3. 변경 범위 — 파일 목록 + +| 파일 | 변경 내용 | +|---|---| +| `lib/features/timer/data/models/today_stats_response_model.dart` | 필드 3개 (`lifetimeMinutes`, `lifetimeSessionCount`, `monthlyMinutes`) 추가 + `toEntity()` 매핑 갱신 | +| `lib/features/timer/domain/entities/today_stats.dart` | 동일 3필드 추가 + `TodayStats.empty()` 의 0 초기값 6필드로 확장 | +| `lib/features/timer/presentation/providers/today_stats_provider.dart` | **게스트 분기에서 6필드 모두 로컬 fold 로 채우도록 확장**. 컴파일 에러 회피 + 단일 진실 소스 보장 | +| `lib/features/timer/presentation/providers/study_stats_provider.dart` | `totalStudyMinutes`, `totalSessionCount`, `monthlyStudyMinutes` 를 `todayStatsNotifierProvider` 위임 한 줄로 단순화. `isAuthenticatedProvider` watch 제거 | +| `*.freezed.dart`, `*.g.dart` | `flutter pub run build_runner build --delete-conflicting-outputs` 로 재생성 | +| `test/features/timer/data/models/today_stats_response_model_test.dart` | 기존 케이스에 새 필드 3개 직렬화·매핑 검증 추가 | +| `test/features/timer/domain/entities/today_stats_test.dart` | fixture 6필드로 확장 (회귀 가드) | +| `test/features/timer/data/repositories/timer_session_repository_impl_test.dart` | fixture 6필드로 확장 (회귀 가드) | +| `test/features/timer/presentation/providers/today_stats_provider_test.dart` | fixture 6필드로 확장 (회귀 가드) | +| `test/features/timer/presentation/providers/study_stats_provider_test.dart` | **신규 파일**. 위임 동작 1 케이스 | + +--- + +## 4. 상세 설계 + +### 4.1 DTO — `today_stats_response_model.dart` + +```dart +@freezed +class TodayStatsResponseModel with _$TodayStatsResponseModel { + const TodayStatsResponseModel._(); + + const factory TodayStatsResponseModel({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStatsResponseModel; + + factory TodayStatsResponseModel.fromJson(Map json) => + _$TodayStatsResponseModelFromJson(json); + + TodayStats toEntity() => TodayStats( + totalMinutes: totalMinutes, + sessionCount: sessionCount, + streak: streak, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: lifetimeSessionCount, + monthlyMinutes: monthlyMinutes, + ); +} +``` + +서버 스펙(api-docs.json:1277) 에 따라 6필드 모두 non-null `int` (0 보장). `required` 그대로 사용. + +### 4.2 Entity — `today_stats.dart` + +```dart +@freezed +class TodayStats with _$TodayStats { + const factory TodayStats({ + required int totalMinutes, + required int sessionCount, + required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, + }) = _TodayStats; + + factory TodayStats.empty() => const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ); +} +``` + +### 4.3 today_stats_provider 게스트 분기 — 6필드 fold 로 확장 + +Entity 가 6필드 `required` 가 되면서 현재 게스트 분기(`today_stats_provider.dart:39`) 가 컴파일 에러. 게스트 모드에서는 로컬 세션 전체가 메모리에 있으므로 fold 로 정확하게 계산 가능 — 이슈가 명시한 의도와 일치. + +```dart +@override +Future build() async { + final isAuthenticated = ref.watch(isAuthenticatedProvider); + final repository = ref.watch(timerSessionRepositoryProvider); + + if (isAuthenticated) { + return repository.getTodayStats(); + } + + // 게스트 모드: 로컬 세션 fold + final sessions = repository.getSessions(); + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + final tomorrow = today.add(const Duration(days: 1)); + final monthStart = DateTime(now.year, now.month, 1); + + final todaySessions = sessions.where((s) { + return s.startedAt.isAfter(today.subtract(const Duration(seconds: 1))) && + s.startedAt.isBefore(tomorrow); + }).toList(); + + final todayMinutes = + todaySessions.fold(0, (sum, s) => sum + s.durationMinutes); + final lifetimeMinutes = + sessions.fold(0, (sum, s) => sum + s.durationMinutes); + final monthlyMinutes = sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); + + return TodayStats( + totalMinutes: todayMinutes, + sessionCount: todaySessions.length, + streak: todaySessions.isEmpty ? 0 : 1, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: sessions.length, + monthlyMinutes: monthlyMinutes, + ); +} +``` + +기존 streak 계산("오늘 세션 있으면 1, 없으면 0") 은 변경하지 않음 — 이슈 범위 밖. + +### 4.4 study_stats_provider 단순화 — `todayStatsNotifierProvider` 위임 + +3개 함수를 한 줄로 단순화. 더 이상 `isAuthenticatedProvider` 를 watch 하지 않고, 분기 책임을 전부 `todayStatsNotifierProvider` 에 위임. 시그니처는 동기 `int` 그대로 유지 — 로딩/에러시 `valueOrNull?.X ?? 0` fallback. + +```dart +import 'today_stats_provider.dart'; + +@riverpod +int totalStudyMinutes(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? 0; +} + +@riverpod +int totalSessionCount(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeSessionCount ?? 0; +} + +@riverpod +int monthlyStudyMinutes(Ref ref) { + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; +} +``` + +**손대지 않는 함수**: `todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak`. 이슈가 명시한 3개만 변경. + +### 4.5 무효화 트리거 — 변경 없음 + +`todayStatsNotifierProvider` 는 이미 4곳에서 invalidate 된다: + +- `lib/features/timer/presentation/providers/timer_provider.dart:174` — 세션 저장 후 +- `lib/features/auth/presentation/providers/auth_provider.dart:334` — 로그인 후 +- `lib/features/auth/presentation/providers/auth_provider.dart:369` — 로그아웃 후 +- `lib/features/auth/presentation/providers/auth_provider.dart:463` — 탈퇴 후 + +신규 누적 필드도 동일 시점에 자동 갱신됨. 추가 invalidate 트리거 불필요. + +--- + +## 5. 호출처 영향 + +### 5.1 `profile_screen.dart:83` + +```dart +final totalMinutes = ref.watch(totalStudyMinutesProvider); +``` + +시그니처 동일 → 코드 변경 없음. +**동작 변화**: 인증 사용자는 최근 20세션 → 전체 누적값으로 정확해짐. +**과도기 UX**: 첫 진입 / invalidate 직후 `todayStatsNotifierProvider` 로딩 동안 0 표시 가능 (기존 코드도 빈 상태에서 0 표시였으므로 신규 깜빡임 아님). + +### 5.2 `badge_provider.dart:53,55` + +```dart +final totalMinutes = ref.read(totalStudyMinutesProvider); +final sessionCount = ref.read(totalSessionCountProvider); +``` + +시그니처 동일 → 코드 변경 없음. +**동작 변화**: "총 100시간 공부", "총 50회 세션" 류 뱃지가 정확한 누적값으로 평가됨 → 정상 해금 가능. + +--- + +## 6. 테스트 계획 + +작업 난이도가 낮으므로 **이슈가 명시한 테스트 + 회귀 가드 (fixture 갱신)** 만 진행. trivial 한 분기는 케이스 추가하지 않는다. + +### 6.1 DTO — `today_stats_response_model_test.dart` + +기존 테스트의 JSON 픽스처에 새 필드 3개 추가하고 6필드 모두 `toEntity()` 매핑이 동작하는지 한 케이스로 검증. + +### 6.2 신규 — `study_stats_provider_test.dart` + +`todayStatsNotifierProvider` override 한 줄로 위임 동작만 검증. **로드 완료 1 케이스만**: + +- `lifetimeMinutes=12450`, `lifetimeSessionCount=287`, `monthlyMinutes=1820` 인 `TodayStats` 주입 → 3 provider 가 각각 일치하는 값 반환. + +loading/error 분기는 Riverpod 표준 동작 (`valueOrNull` 이 null 반환) 이므로 별도 케이스 불필요. + +### 6.3 회귀 가드 — fixture 6필드 갱신 + +DTO/Entity 가 6필드 `required` 가 되면서 기존 픽스처는 컴파일 에러. 다음 4개 파일에서 6필드로 확장 (추가 필드는 모두 0): + +| 파일:라인 | 갱신 | +|---|---| +| `today_stats_test.dart:6` | `TodayStats(...)` 6필드 | +| `today_stats_test.dart:15` | `TodayStats(...)` 6필드 모두 0 (`empty()` 비교 대상) | +| `today_stats_provider_test.dart:26` | `TodayStats(...)` 6필드 | +| `timer_session_repository_impl_test.dart:173-177` | `TodayStatsResponseModel(...)` 6필드 | + +기존 테스트 케이스 자체는 추가하지 않음. 컴파일 통과만 확보. + +--- + +## 7. 명시적 비범위(Out of Scope) + +다음은 이 이슈에서 **건드리지 않는다**. 별도 이슈로 다룰 사안: + +- `todayStudyMinutes`, `weeklyStudyMinutes`, `currentStreak` provider — 이슈가 지목한 3개만 변경. +- `today_stats_provider.dart` 게스트 분기의 **streak 계산 로직** — 현재 "오늘 있으면 1, 없으면 0" 단순 로직 그대로. lifetime/monthly 필드 추가만 진행. +- `today_stats_provider.dart` 의 인증 분기 — 이미 `repository.getTodayStats()` 위임 패턴 동작 중. 변경 없음. +- 새 invalidate 트리거 — 기존 4곳(timer save 1, auth 3) 으로 충분. +- UI 신규 컴포넌트 — 시그니처가 동일해서 호출처(profile_screen, badge_provider) 수정 불필요. +- 백엔드 작업 — 이미 완료, `docs/api-docs.json` 동기화 끝남. + +--- + +## 8. 검증 체크리스트 + +- [ ] `flutter pub run build_runner build --delete-conflicting-outputs` 성공 +- [ ] `flutter analyze` 경고 0 +- [ ] `flutter test test/features/timer/` 전부 통과 +- [ ] `git grep -n 'TodayStats('` 결과의 모든 호출처 — 신규 6필드 또는 `.empty()` 사용 일관 (§6.4 4개 파일 갱신 완료 여부 확인) +- [ ] `git grep -n 'TodayStatsResponseModel('` 결과 — `timer_session_repository_impl_test.dart` 픽스처 6필드 갱신 완료 +- [ ] profile_screen / badge_provider 호출처 — 코드 미수정 상태로 정상 동작 (시그니처 동일) +- [ ] flutter run 으로 인증 사용자 프로필 화면 진입 → "공부 시간" 카드가 누적값 표시 (서버 정확값 검증) diff --git a/lib/features/timer/data/models/today_stats_response_model.dart b/lib/features/timer/data/models/today_stats_response_model.dart index 99b2360..b1ba70d 100644 --- a/lib/features/timer/data/models/today_stats_response_model.dart +++ b/lib/features/timer/data/models/today_stats_response_model.dart @@ -13,6 +13,9 @@ class TodayStatsResponseModel with _$TodayStatsResponseModel { required int totalMinutes, required int sessionCount, required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, }) = _TodayStatsResponseModel; factory TodayStatsResponseModel.fromJson(Map json) => @@ -22,5 +25,8 @@ class TodayStatsResponseModel with _$TodayStatsResponseModel { totalMinutes: totalMinutes, sessionCount: sessionCount, streak: streak, + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: lifetimeSessionCount, + monthlyMinutes: monthlyMinutes, ); } diff --git a/lib/features/timer/data/models/today_stats_response_model.freezed.dart b/lib/features/timer/data/models/today_stats_response_model.freezed.dart index 9da2e0d..062af31 100644 --- a/lib/features/timer/data/models/today_stats_response_model.freezed.dart +++ b/lib/features/timer/data/models/today_stats_response_model.freezed.dart @@ -26,6 +26,9 @@ mixin _$TodayStatsResponseModel { int get totalMinutes => throw _privateConstructorUsedError; int get sessionCount => throw _privateConstructorUsedError; int get streak => throw _privateConstructorUsedError; + int get lifetimeMinutes => throw _privateConstructorUsedError; + int get lifetimeSessionCount => throw _privateConstructorUsedError; + int get monthlyMinutes => throw _privateConstructorUsedError; /// Serializes this TodayStatsResponseModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -44,7 +47,14 @@ abstract class $TodayStatsResponseModelCopyWith<$Res> { $Res Function(TodayStatsResponseModel) then, ) = _$TodayStatsResponseModelCopyWithImpl<$Res, TodayStatsResponseModel>; @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -68,6 +78,9 @@ class _$TodayStatsResponseModelCopyWithImpl< Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _value.copyWith( @@ -83,6 +96,18 @@ class _$TodayStatsResponseModelCopyWithImpl< ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ) as $Val, ); @@ -98,7 +123,14 @@ abstract class _$$TodayStatsResponseModelImplCopyWith<$Res> ) = __$$TodayStatsResponseModelImplCopyWithImpl<$Res>; @override @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -122,6 +154,9 @@ class __$$TodayStatsResponseModelImplCopyWithImpl<$Res> Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _$TodayStatsResponseModelImpl( @@ -137,6 +172,18 @@ class __$$TodayStatsResponseModelImplCopyWithImpl<$Res> ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ), ); } @@ -149,6 +196,9 @@ class _$TodayStatsResponseModelImpl extends _TodayStatsResponseModel { required this.totalMinutes, required this.sessionCount, required this.streak, + required this.lifetimeMinutes, + required this.lifetimeSessionCount, + required this.monthlyMinutes, }) : super._(); factory _$TodayStatsResponseModelImpl.fromJson(Map json) => @@ -160,10 +210,16 @@ class _$TodayStatsResponseModelImpl extends _TodayStatsResponseModel { final int sessionCount; @override final int streak; + @override + final int lifetimeMinutes; + @override + final int lifetimeSessionCount; + @override + final int monthlyMinutes; @override String toString() { - return 'TodayStatsResponseModel(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak)'; + return 'TodayStatsResponseModel(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak, lifetimeMinutes: $lifetimeMinutes, lifetimeSessionCount: $lifetimeSessionCount, monthlyMinutes: $monthlyMinutes)'; } @override @@ -175,13 +231,26 @@ class _$TodayStatsResponseModelImpl extends _TodayStatsResponseModel { other.totalMinutes == totalMinutes) && (identical(other.sessionCount, sessionCount) || other.sessionCount == sessionCount) && - (identical(other.streak, streak) || other.streak == streak)); + (identical(other.streak, streak) || other.streak == streak) && + (identical(other.lifetimeMinutes, lifetimeMinutes) || + other.lifetimeMinutes == lifetimeMinutes) && + (identical(other.lifetimeSessionCount, lifetimeSessionCount) || + other.lifetimeSessionCount == lifetimeSessionCount) && + (identical(other.monthlyMinutes, monthlyMinutes) || + other.monthlyMinutes == monthlyMinutes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => - Object.hash(runtimeType, totalMinutes, sessionCount, streak); + int get hashCode => Object.hash( + runtimeType, + totalMinutes, + sessionCount, + streak, + lifetimeMinutes, + lifetimeSessionCount, + monthlyMinutes, + ); /// Create a copy of TodayStatsResponseModel /// with the given fields replaced by the non-null parameter values. @@ -205,6 +274,9 @@ abstract class _TodayStatsResponseModel extends TodayStatsResponseModel { required final int totalMinutes, required final int sessionCount, required final int streak, + required final int lifetimeMinutes, + required final int lifetimeSessionCount, + required final int monthlyMinutes, }) = _$TodayStatsResponseModelImpl; const _TodayStatsResponseModel._() : super._(); @@ -217,6 +289,12 @@ abstract class _TodayStatsResponseModel extends TodayStatsResponseModel { int get sessionCount; @override int get streak; + @override + int get lifetimeMinutes; + @override + int get lifetimeSessionCount; + @override + int get monthlyMinutes; /// Create a copy of TodayStatsResponseModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/features/timer/data/models/today_stats_response_model.g.dart b/lib/features/timer/data/models/today_stats_response_model.g.dart index 74088de..990cfcb 100644 --- a/lib/features/timer/data/models/today_stats_response_model.g.dart +++ b/lib/features/timer/data/models/today_stats_response_model.g.dart @@ -12,6 +12,9 @@ _$TodayStatsResponseModelImpl _$$TodayStatsResponseModelImplFromJson( totalMinutes: (json['totalMinutes'] as num).toInt(), sessionCount: (json['sessionCount'] as num).toInt(), streak: (json['streak'] as num).toInt(), + lifetimeMinutes: (json['lifetimeMinutes'] as num).toInt(), + lifetimeSessionCount: (json['lifetimeSessionCount'] as num).toInt(), + monthlyMinutes: (json['monthlyMinutes'] as num).toInt(), ); Map _$$TodayStatsResponseModelImplToJson( @@ -20,4 +23,7 @@ Map _$$TodayStatsResponseModelImplToJson( 'totalMinutes': instance.totalMinutes, 'sessionCount': instance.sessionCount, 'streak': instance.streak, + 'lifetimeMinutes': instance.lifetimeMinutes, + 'lifetimeSessionCount': instance.lifetimeSessionCount, + 'monthlyMinutes': instance.monthlyMinutes, }; diff --git a/lib/features/timer/domain/entities/today_stats.dart b/lib/features/timer/domain/entities/today_stats.dart index dae3249..e96e947 100644 --- a/lib/features/timer/domain/entities/today_stats.dart +++ b/lib/features/timer/domain/entities/today_stats.dart @@ -2,15 +2,24 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'today_stats.freezed.dart'; -/// KST 기준 오늘 공부 통계 (GET /api/timer-sessions/today-stats). +/// KST 기준 오늘 공부 통계 + 누적 통계 (GET /api/timer-sessions/today-stats). @freezed class TodayStats with _$TodayStats { const factory TodayStats({ required int totalMinutes, required int sessionCount, required int streak, + required int lifetimeMinutes, + required int lifetimeSessionCount, + required int monthlyMinutes, }) = _TodayStats; - factory TodayStats.empty() => - const TodayStats(totalMinutes: 0, sessionCount: 0, streak: 0); + factory TodayStats.empty() => const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ); } diff --git a/lib/features/timer/domain/entities/today_stats.freezed.dart b/lib/features/timer/domain/entities/today_stats.freezed.dart index 62c50e0..7b3f8d3 100644 --- a/lib/features/timer/domain/entities/today_stats.freezed.dart +++ b/lib/features/timer/domain/entities/today_stats.freezed.dart @@ -20,6 +20,9 @@ mixin _$TodayStats { int get totalMinutes => throw _privateConstructorUsedError; int get sessionCount => throw _privateConstructorUsedError; int get streak => throw _privateConstructorUsedError; + int get lifetimeMinutes => throw _privateConstructorUsedError; + int get lifetimeSessionCount => throw _privateConstructorUsedError; + int get monthlyMinutes => throw _privateConstructorUsedError; /// Create a copy of TodayStats /// with the given fields replaced by the non-null parameter values. @@ -35,7 +38,14 @@ abstract class $TodayStatsCopyWith<$Res> { $Res Function(TodayStats) then, ) = _$TodayStatsCopyWithImpl<$Res, TodayStats>; @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -56,6 +66,9 @@ class _$TodayStatsCopyWithImpl<$Res, $Val extends TodayStats> Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _value.copyWith( @@ -71,6 +84,18 @@ class _$TodayStatsCopyWithImpl<$Res, $Val extends TodayStats> ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ) as $Val, ); @@ -86,7 +111,14 @@ abstract class _$$TodayStatsImplCopyWith<$Res> ) = __$$TodayStatsImplCopyWithImpl<$Res>; @override @useResult - $Res call({int totalMinutes, int sessionCount, int streak}); + $Res call({ + int totalMinutes, + int sessionCount, + int streak, + int lifetimeMinutes, + int lifetimeSessionCount, + int monthlyMinutes, + }); } /// @nodoc @@ -106,6 +138,9 @@ class __$$TodayStatsImplCopyWithImpl<$Res> Object? totalMinutes = null, Object? sessionCount = null, Object? streak = null, + Object? lifetimeMinutes = null, + Object? lifetimeSessionCount = null, + Object? monthlyMinutes = null, }) { return _then( _$TodayStatsImpl( @@ -121,6 +156,18 @@ class __$$TodayStatsImplCopyWithImpl<$Res> ? _value.streak : streak // ignore: cast_nullable_to_non_nullable as int, + lifetimeMinutes: null == lifetimeMinutes + ? _value.lifetimeMinutes + : lifetimeMinutes // ignore: cast_nullable_to_non_nullable + as int, + lifetimeSessionCount: null == lifetimeSessionCount + ? _value.lifetimeSessionCount + : lifetimeSessionCount // ignore: cast_nullable_to_non_nullable + as int, + monthlyMinutes: null == monthlyMinutes + ? _value.monthlyMinutes + : monthlyMinutes // ignore: cast_nullable_to_non_nullable + as int, ), ); } @@ -133,6 +180,9 @@ class _$TodayStatsImpl implements _TodayStats { required this.totalMinutes, required this.sessionCount, required this.streak, + required this.lifetimeMinutes, + required this.lifetimeSessionCount, + required this.monthlyMinutes, }); @override @@ -141,10 +191,16 @@ class _$TodayStatsImpl implements _TodayStats { final int sessionCount; @override final int streak; + @override + final int lifetimeMinutes; + @override + final int lifetimeSessionCount; + @override + final int monthlyMinutes; @override String toString() { - return 'TodayStats(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak)'; + return 'TodayStats(totalMinutes: $totalMinutes, sessionCount: $sessionCount, streak: $streak, lifetimeMinutes: $lifetimeMinutes, lifetimeSessionCount: $lifetimeSessionCount, monthlyMinutes: $monthlyMinutes)'; } @override @@ -156,12 +212,25 @@ class _$TodayStatsImpl implements _TodayStats { other.totalMinutes == totalMinutes) && (identical(other.sessionCount, sessionCount) || other.sessionCount == sessionCount) && - (identical(other.streak, streak) || other.streak == streak)); + (identical(other.streak, streak) || other.streak == streak) && + (identical(other.lifetimeMinutes, lifetimeMinutes) || + other.lifetimeMinutes == lifetimeMinutes) && + (identical(other.lifetimeSessionCount, lifetimeSessionCount) || + other.lifetimeSessionCount == lifetimeSessionCount) && + (identical(other.monthlyMinutes, monthlyMinutes) || + other.monthlyMinutes == monthlyMinutes)); } @override - int get hashCode => - Object.hash(runtimeType, totalMinutes, sessionCount, streak); + int get hashCode => Object.hash( + runtimeType, + totalMinutes, + sessionCount, + streak, + lifetimeMinutes, + lifetimeSessionCount, + monthlyMinutes, + ); /// Create a copy of TodayStats /// with the given fields replaced by the non-null parameter values. @@ -177,6 +246,9 @@ abstract class _TodayStats implements TodayStats { required final int totalMinutes, required final int sessionCount, required final int streak, + required final int lifetimeMinutes, + required final int lifetimeSessionCount, + required final int monthlyMinutes, }) = _$TodayStatsImpl; @override @@ -185,6 +257,12 @@ abstract class _TodayStats implements TodayStats { int get sessionCount; @override int get streak; + @override + int get lifetimeMinutes; + @override + int get lifetimeSessionCount; + @override + int get monthlyMinutes; /// Create a copy of TodayStats /// with the given fields replaced by the non-null parameter values. diff --git a/lib/features/timer/presentation/providers/study_stats_provider.dart b/lib/features/timer/presentation/providers/study_stats_provider.dart index 9d949d9..8ffd34c 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.dart @@ -1,97 +1,47 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../utils/timer_format_utils.dart'; -import 'timer_session_provider.dart'; +import 'today_stats_provider.dart'; part 'study_stats_provider.g.dart'; -// === 일별/주별 통계 === +// === 일별 통계 === -/// 오늘 공부 시간 (분) +/// 오늘 공부 시간 (분) — todayStats 위임 @riverpod int todayStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final today = normalizeDate(DateTime.now()); - - return sessions - .where((s) => normalizeDate(s.startedAt) == today) - .fold(0, (sum, s) => sum + s.durationMinutes); -} - -/// 이번 주 공부 시간 (분) — 최근 7일 -@riverpod -int weeklyStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final now = DateTime.now(); - final weekStart = normalizeDate(now.subtract(const Duration(days: 6))); - - return sessions - .where((s) => !normalizeDate(s.startedAt).isBefore(weekStart)) - .fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.totalMinutes ?? 0; } // === 월별/전체 통계 === -/// 이번 달 공부 시간 (분) +/// 이번 달 공부 시간 (분) — todayStats 위임 @riverpod int monthlyStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - final now = DateTime.now(); - final monthStart = DateTime(now.year, now.month, 1); - - return sessions - .where((s) => !s.startedAt.isBefore(monthStart)) - .fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.monthlyMinutes ?? 0; } -/// 전체 총 공부 시간 (분) +/// 전체 총 공부 시간 (분) — todayStats 위임 @riverpod int totalStudyMinutes(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - return sessions.fold(0, (sum, s) => sum + s.durationMinutes); + return ref.watch(todayStatsNotifierProvider).valueOrNull?.lifetimeMinutes ?? + 0; } -/// 전체 세션 수 +/// 전체 세션 수 — todayStats 위임 @riverpod int totalSessionCount(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - return sessions.length; + return ref + .watch(todayStatsNotifierProvider) + .valueOrNull + ?.lifetimeSessionCount ?? + 0; } // === Streak === -/// 연속 공부 일수 (streak) +/// 연속 공부 일수 (streak) — todayStats 위임 @riverpod int currentStreak(Ref ref) { - final sessions = - ref.watch(timerSessionListNotifierProvider).valueOrNull ?? []; - if (sessions.isEmpty) return 0; - - // 세션이 있는 날짜 집합 - final studyDates = - sessions.map((s) => normalizeDate(s.startedAt)).toSet().toList() - ..sort((a, b) => b.compareTo(a)); // 최신순 - - final today = normalizeDate(DateTime.now()); - final yesterday = today.subtract(const Duration(days: 1)); - - // 오늘 또는 어제 공부하지 않았으면 streak 0 - if (studyDates.first != today && studyDates.first != yesterday) return 0; - - int streak = 1; - for (int i = 1; i < studyDates.length; i++) { - final diff = studyDates[i - 1].difference(studyDates[i]).inDays; - if (diff == 1) { - streak++; - } else { - break; - } - } - return streak; + return ref.watch(todayStatsNotifierProvider).valueOrNull?.streak ?? 0; } diff --git a/lib/features/timer/presentation/providers/study_stats_provider.g.dart b/lib/features/timer/presentation/providers/study_stats_provider.g.dart index 1d104c6..962d888 100644 --- a/lib/features/timer/presentation/providers/study_stats_provider.g.dart +++ b/lib/features/timer/presentation/providers/study_stats_provider.g.dart @@ -6,9 +6,9 @@ part of 'study_stats_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$todayStudyMinutesHash() => r'945b739e7bb40dbb04d6d397d4cecb7a2664bf79'; +String _$todayStudyMinutesHash() => r'c9211d1880bad4ccae60eca4b69c8ed6f0ba0b00'; -/// 오늘 공부 시간 (분) +/// 오늘 공부 시간 (분) — todayStats 위임 /// /// Copied from [todayStudyMinutes]. @ProviderFor(todayStudyMinutes) @@ -25,30 +25,10 @@ final todayStudyMinutesProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TodayStudyMinutesRef = AutoDisposeProviderRef; -String _$weeklyStudyMinutesHash() => - r'f972b83f62e0641552e1fe32d5d04482e9f55074'; - -/// 이번 주 공부 시간 (분) — 최근 7일 -/// -/// Copied from [weeklyStudyMinutes]. -@ProviderFor(weeklyStudyMinutes) -final weeklyStudyMinutesProvider = AutoDisposeProvider.internal( - weeklyStudyMinutes, - name: r'weeklyStudyMinutesProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$weeklyStudyMinutesHash, - dependencies: null, - allTransitiveDependencies: null, -); - -@Deprecated('Will be removed in 3.0. Use Ref instead') -// ignore: unused_element -typedef WeeklyStudyMinutesRef = AutoDisposeProviderRef; String _$monthlyStudyMinutesHash() => - r'0848eb2ad73d324eaf87dd350f6b857fd05b08fa'; + r'46281f77813a3b09026ab3aa09dbc3733e907a72'; -/// 이번 달 공부 시간 (분) +/// 이번 달 공부 시간 (분) — todayStats 위임 /// /// Copied from [monthlyStudyMinutes]. @ProviderFor(monthlyStudyMinutes) @@ -65,9 +45,9 @@ final monthlyStudyMinutesProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef MonthlyStudyMinutesRef = AutoDisposeProviderRef; -String _$totalStudyMinutesHash() => r'8477df0e108d2a18ac8dcd546bd05ecf93fd2adb'; +String _$totalStudyMinutesHash() => r'bd91b30decff70dbb57e5649252cff18b02607b9'; -/// 전체 총 공부 시간 (분) +/// 전체 총 공부 시간 (분) — todayStats 위임 /// /// Copied from [totalStudyMinutes]. @ProviderFor(totalStudyMinutes) @@ -84,9 +64,9 @@ final totalStudyMinutesProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TotalStudyMinutesRef = AutoDisposeProviderRef; -String _$totalSessionCountHash() => r'dee88fc7f314f89bad35757ae90d9f066372b401'; +String _$totalSessionCountHash() => r'76529512582e8e028fb000f1a4791f4095cb80a7'; -/// 전체 세션 수 +/// 전체 세션 수 — todayStats 위임 /// /// Copied from [totalSessionCount]. @ProviderFor(totalSessionCount) @@ -103,9 +83,9 @@ final totalSessionCountProvider = AutoDisposeProvider.internal( @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef TotalSessionCountRef = AutoDisposeProviderRef; -String _$currentStreakHash() => r'695c3e5cc1b3eef210917de2240ab38f7a862007'; +String _$currentStreakHash() => r'e7f3905bd5bd21ca744b530a63022031b5d2ad03'; -/// 연속 공부 일수 (streak) +/// 연속 공부 일수 (streak) — todayStats 위임 /// /// Copied from [currentStreak]. @ProviderFor(currentStreak) diff --git a/lib/features/timer/presentation/providers/today_stats_provider.dart b/lib/features/timer/presentation/providers/today_stats_provider.dart index f50c4e1..fc0fb08 100644 --- a/lib/features/timer/presentation/providers/today_stats_provider.dart +++ b/lib/features/timer/presentation/providers/today_stats_provider.dart @@ -1,7 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../../auth/presentation/providers/auth_provider.dart'; +import '../../domain/entities/timer_session_entity.dart'; import '../../domain/entities/today_stats.dart'; +import '../utils/timer_format_utils.dart'; import 'timer_session_provider.dart'; part 'today_stats_provider.g.dart'; @@ -20,26 +22,62 @@ class TodayStatsNotifier extends _$TodayStatsNotifier { return repository.getTodayStats(); } - // 게스트 모드: 로컬 계산 + // 게스트 모드: 로컬 세션 fold (전체가 메모리에 있어 정확) final sessions = repository.getSessions(); final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final tomorrow = today.add(const Duration(days: 1)); + final monthStart = DateTime(now.year, now.month, 1); final todaySessions = sessions.where((s) { return s.startedAt.isAfter(today.subtract(const Duration(seconds: 1))) && s.startedAt.isBefore(tomorrow); }).toList(); - final totalMinutes = todaySessions.fold( + final todayMinutes = todaySessions.fold( 0, (sum, s) => sum + s.durationMinutes, ); + final lifetimeMinutes = sessions.fold( + 0, + (sum, s) => sum + s.durationMinutes, + ); + final monthlyMinutes = sessions + .where((s) => !s.startedAt.isBefore(monthStart)) + .fold(0, (sum, s) => sum + s.durationMinutes); return TodayStats( - totalMinutes: totalMinutes, + totalMinutes: todayMinutes, sessionCount: todaySessions.length, - streak: todaySessions.isEmpty ? 0 : 1, + streak: _calculateStreak(sessions), + lifetimeMinutes: lifetimeMinutes, + lifetimeSessionCount: sessions.length, + monthlyMinutes: monthlyMinutes, ); } + + /// 연속 공부 일수 계산 (오늘 또는 어제 공부했어야 streak 유지). + int _calculateStreak(List sessions) { + if (sessions.isEmpty) return 0; + + final studyDates = + sessions.map((s) => normalizeDate(s.startedAt)).toSet().toList() + ..sort((a, b) => b.compareTo(a)); + + final today = normalizeDate(DateTime.now()); + final yesterday = today.subtract(const Duration(days: 1)); + + if (studyDates.first != today && studyDates.first != yesterday) return 0; + + int streak = 1; + for (int i = 1; i < studyDates.length; i++) { + final diff = studyDates[i - 1].difference(studyDates[i]).inDays; + if (diff == 1) { + streak++; + } else { + break; + } + } + return streak; + } } diff --git a/lib/features/timer/presentation/providers/today_stats_provider.g.dart b/lib/features/timer/presentation/providers/today_stats_provider.g.dart index a074c3b..f177d58 100644 --- a/lib/features/timer/presentation/providers/today_stats_provider.g.dart +++ b/lib/features/timer/presentation/providers/today_stats_provider.g.dart @@ -7,7 +7,7 @@ part of 'today_stats_provider.dart'; // ************************************************************************** String _$todayStatsNotifierHash() => - r'36b4b93510cf2738ac95809c6573d3a881c4410b'; + r'fc66cbacb28f5be7cfe7947a9a6499f882c80921'; /// 오늘 공부 통계 provider. /// 인증 모드: 서버 GET /api/timer-sessions/today-stats 결과 신뢰. diff --git a/lib/features/timer/presentation/screens/timer_screen.dart b/lib/features/timer/presentation/screens/timer_screen.dart index 02120b1..a474311 100644 --- a/lib/features/timer/presentation/screens/timer_screen.dart +++ b/lib/features/timer/presentation/screens/timer_screen.dart @@ -113,7 +113,7 @@ class _TimerScreenState extends ConsumerState { child: Consumer( builder: (context, ref, _) { final todayMinutes = ref.watch(todayStudyMinutesProvider); - final weeklyMinutes = ref.watch(weeklyStudyMinutesProvider); + final monthlyMinutes = ref.watch(monthlyStudyMinutesProvider); final streak = ref.watch(currentStreakProvider); return Padding( padding: AppPadding.horizontal20, @@ -134,9 +134,9 @@ class _TimerScreenState extends ConsumerState { color: AppColors.spaceDivider, ), SpaceStatItem( - icon: Icons.date_range_rounded, - label: '이번 주', - value: formatMinutes(weeklyMinutes), + icon: Icons.calendar_month_rounded, + label: '이번 달', + value: formatMinutes(monthlyMinutes), ), Container( width: 1, diff --git a/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart b/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart index bb5a4b1..e11ce11 100644 --- a/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart +++ b/test/features/timer/data/datasources/timer_session_remote_datasource_test.dart @@ -82,12 +82,17 @@ void main() { test('getTodayStats: GET 호출', () async { when(() => adapter.fetch(any(), any(), any())).thenAnswer( - (inv) async => - jsonBody(200, '{"totalMinutes":180,"sessionCount":3,"streak":7}'), + (inv) async => jsonBody( + 200, + '{"totalMinutes":180,"sessionCount":3,"streak":7,"lifetimeMinutes":12450,"lifetimeSessionCount":287,"monthlyMinutes":1820}', + ), ); final res = await api.getTodayStats(); expect(res.totalMinutes, 180); expect(res.streak, 7); + expect(res.lifetimeMinutes, 12450); + expect(res.lifetimeSessionCount, 287); + expect(res.monthlyMinutes, 1820); }); } diff --git a/test/features/timer/data/models/today_stats_response_model_test.dart b/test/features/timer/data/models/today_stats_response_model_test.dart index f084ef9..7a31dfa 100644 --- a/test/features/timer/data/models/today_stats_response_model_test.dart +++ b/test/features/timer/data/models/today_stats_response_model_test.dart @@ -2,16 +2,28 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:space_study_ship/features/timer/data/models/today_stats_response_model.dart'; void main() { - test('fromJson + toEntity', () { + test('fromJson + toEntity — 6필드 모두 매핑', () { final m = TodayStatsResponseModel.fromJson({ 'totalMinutes': 180, 'sessionCount': 3, 'streak': 7, + 'lifetimeMinutes': 12450, + 'lifetimeSessionCount': 287, + 'monthlyMinutes': 1820, }); expect(m.totalMinutes, 180); - final entity = m.toEntity(); - expect(entity.totalMinutes, 180); - expect(entity.sessionCount, 3); - expect(entity.streak, 7); + expect(m.sessionCount, 3); + expect(m.streak, 7); + expect(m.lifetimeMinutes, 12450); + expect(m.lifetimeSessionCount, 287); + expect(m.monthlyMinutes, 1820); + + final e = m.toEntity(); + expect(e.totalMinutes, 180); + expect(e.sessionCount, 3); + expect(e.streak, 7); + expect(e.lifetimeMinutes, 12450); + expect(e.lifetimeSessionCount, 287); + expect(e.monthlyMinutes, 1820); }); } diff --git a/test/features/timer/data/repositories/timer_session_repository_impl_test.dart b/test/features/timer/data/repositories/timer_session_repository_impl_test.dart index 32fc7c3..ab069d9 100644 --- a/test/features/timer/data/repositories/timer_session_repository_impl_test.dart +++ b/test/features/timer/data/repositories/timer_session_repository_impl_test.dart @@ -174,11 +174,17 @@ void main() { totalMinutes: 180, sessionCount: 3, streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, ), ); final stats = await repo.getTodayStats(); expect(stats.totalMinutes, 180); expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); + expect(stats.lifetimeSessionCount, 287); + expect(stats.monthlyMinutes, 1820); }); }); diff --git a/test/features/timer/domain/entities/today_stats_test.dart b/test/features/timer/domain/entities/today_stats_test.dart index 4f1f39f..0988961 100644 --- a/test/features/timer/domain/entities/today_stats_test.dart +++ b/test/features/timer/domain/entities/today_stats_test.dart @@ -2,17 +2,34 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; void main() { - test('TodayStats 필드 보유', () { - const stats = TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7); + test('TodayStats — 6필드 생성', () { + const stats = TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); expect(stats.totalMinutes, 180); + expect(stats.lifetimeMinutes, 12450); + expect(stats.monthlyMinutes, 1820); expect(stats.sessionCount, 3); expect(stats.streak, 7); + expect(stats.lifetimeSessionCount, 287); }); - test('TodayStats.empty: 모두 0', () { + test('TodayStats.empty(): 6필드 모두 0', () { expect( TodayStats.empty(), - const TodayStats(totalMinutes: 0, sessionCount: 0, streak: 0), + const TodayStats( + totalMinutes: 0, + sessionCount: 0, + streak: 0, + lifetimeMinutes: 0, + lifetimeSessionCount: 0, + monthlyMinutes: 0, + ), ); }); } diff --git a/test/features/timer/presentation/providers/study_stats_provider_test.dart b/test/features/timer/presentation/providers/study_stats_provider_test.dart new file mode 100644 index 0000000..aabc73d --- /dev/null +++ b/test/features/timer/presentation/providers/study_stats_provider_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:space_study_ship/features/timer/domain/entities/today_stats.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/study_stats_provider.dart'; +import 'package:space_study_ship/features/timer/presentation/providers/today_stats_provider.dart'; + +void main() { + test('todayStats 로드 완료 → 5 provider 가 모두 todayStats 값 위임', () async { + const stats = TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ); + + final container = ProviderContainer( + overrides: [ + todayStatsNotifierProvider.overrideWith(() { + return _StubTodayStatsNotifier(stats); + }), + ], + ); + addTearDown(container.dispose); + + // todayStats 비동기 로드 완료 대기 + await container.read(todayStatsNotifierProvider.future); + + expect(container.read(todayStudyMinutesProvider), 180); + expect(container.read(monthlyStudyMinutesProvider), 1820); + expect(container.read(totalStudyMinutesProvider), 12450); + expect(container.read(totalSessionCountProvider), 287); + expect(container.read(currentStreakProvider), 7); + }); +} + +class _StubTodayStatsNotifier extends TodayStatsNotifier { + _StubTodayStatsNotifier(this._stats); + final TodayStats _stats; + + @override + Future build() async => _stats; +} diff --git a/test/features/timer/presentation/providers/today_stats_provider_test.dart b/test/features/timer/presentation/providers/today_stats_provider_test.dart index 9d43e87..21ade2b 100644 --- a/test/features/timer/presentation/providers/today_stats_provider_test.dart +++ b/test/features/timer/presentation/providers/today_stats_provider_test.dart @@ -22,8 +22,14 @@ void main() { test('인증 모드: repo.getTodayStats() 결과 그대로 반환', () async { when(() => repo.getTodayStats()).thenAnswer( - (_) async => - const TodayStats(totalMinutes: 180, sessionCount: 3, streak: 7), + (_) async => const TodayStats( + totalMinutes: 180, + sessionCount: 3, + streak: 7, + lifetimeMinutes: 12450, + lifetimeSessionCount: 287, + monthlyMinutes: 1820, + ), ); container = ProviderContainer( overrides: [ @@ -34,6 +40,7 @@ void main() { final stats = await container.read(todayStatsNotifierProvider.future); expect(stats.totalMinutes, 180); expect(stats.streak, 7); + expect(stats.lifetimeMinutes, 12450); }); test('게스트 모드: 로컬 세션으로 직접 계산 (오늘 KST 기준)', () async { @@ -66,5 +73,47 @@ void main() { expect(stats.sessionCount, 2); expect(stats.totalMinutes, 90); expect(stats.streak, 1); + expect(stats.lifetimeMinutes, 90); // 게스트는 전체 = today 합산 + expect(stats.lifetimeSessionCount, 2); + expect(stats.monthlyMinutes, 90); + }); + + test('게스트 모드: 다일 streak 계산 — 오늘·어제·그저께 연속 공부', () async { + final now = DateTime.now(); + final todayNoon = DateTime(now.year, now.month, now.day, 12, 0); + when(() => repo.getSessions()).thenReturn([ + TimerSessionEntity( + id: 's-today', + startedAt: todayNoon, + endedAt: todayNoon.add(const Duration(hours: 1)), + durationMinutes: 60, + ), + TimerSessionEntity( + id: 's-yesterday', + startedAt: todayNoon.subtract(const Duration(days: 1)), + endedAt: todayNoon + .subtract(const Duration(days: 1)) + .add(const Duration(hours: 1)), + durationMinutes: 60, + ), + TimerSessionEntity( + id: 's-day-before', + startedAt: todayNoon.subtract(const Duration(days: 2)), + endedAt: todayNoon + .subtract(const Duration(days: 2)) + .add(const Duration(hours: 1)), + durationMinutes: 60, + ), + ]); + + container = ProviderContainer( + overrides: [ + isAuthenticatedProvider.overrideWith((ref) => false), + timerSessionRepositoryProvider.overrideWith((ref) => repo), + ], + ); + + final stats = await container.read(todayStatsNotifierProvider.future); + expect(stats.streak, 3); }); }