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 @@ -238,8 +238,8 @@ class MindRecordLifecycleAndroidTest {
),
dailyQuestions =
listOf(
WeeklyReportDailyQuestion("같은 날 질문", "답변 A", wednesday.toString()),
WeeklyReportDailyQuestion("다른 날 질문", "답변 B", friday.toString()),
WeeklyReportDailyQuestion("같은 날 질문", "답변 A", wednesday),
WeeklyReportDailyQuestion("다른 날 질문", "답변 B", friday),
),
emotions = emptyList(),
// 이 테스트들은 분석 상태를 보지 않는다 — 완료로 고정한다 (#725).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -970,7 +970,7 @@ private fun completeWeeklyReport(monday: LocalDate): WeeklyReport =
WeeklyReportDailyQuestion(
title = "화요일의 질문",
content = "화요일의 답변",
date = monday.plusDays(1).toString(),
date = monday.plusDays(1),
),
),
emotions =
Expand Down
3 changes: 3 additions & 0 deletions feature/mindrecord/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ plugins {

android {
namespace = "com.afternote.feature.mindrecord.data"

// 매퍼가 해석 실패를 android.util.Log 로 남긴다 — 순수 JUnit 에서 그 호출이 죽지 않게 한다.
testOptions.unitTests.isReturnDefaultValues = true
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.afternote.feature.mindrecord.data.mapper

import java.time.LocalDate
import java.time.format.DateTimeFormatter

/**
* 서버가 내려주는 날짜 문자열을 [LocalDate] 로 해석한다 (해석 못 하면 null).
*
* 응답 포맷 지식을 **DTO→도메인 경계에 둔다.** 종전에는 `WeeklyReportViewModel` 이
* "서버가 `yyyy.MM.dd 요일` 또는 ISO 로 내려준다" 는 사실을 알고 직접 파싱했다 —
* presentation 이 와이어 포맷을 아는 구조라, 도메인 소비자가 늘 때마다 같은 파싱을
* 다시 구현해야 했다 (#547).
*
* 세 포맷을 모두 허용한다. `ISO_DATE` 만 두면 시각이 붙은 값(`2026-03-21T20:13:42`)에서
* 뒤가 남아 파싱이 실패한다. 요일이 붙은 값(`2026.05.22 금`)은 앞 조각만 쓴다.
*/
internal fun parseServerDateOrNull(raw: String): LocalDate? {
val datePart = raw.substringBefore(' ').trim()
if (datePart.isEmpty()) return null
for (formatter in SERVER_DATE_FORMATTERS) {
val parsed = runCatching { LocalDate.parse(datePart, formatter) }.getOrNull()
if (parsed != null) return parsed
}
return null
}

private val SERVER_DATE_FORMATTERS: List<DateTimeFormatter> =
listOf(
DateTimeFormatter.ofPattern("yyyy.MM.dd"),
DateTimeFormatter.ISO_DATE,
DateTimeFormatter.ISO_DATE_TIME,
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.afternote.feature.mindrecord.data.mapper

import android.util.Log
import com.afternote.feature.mindrecord.data.dto.EmotionAnalysisSummaryDto
import com.afternote.feature.mindrecord.data.dto.WeeklyReportDailyQuestionDto
import com.afternote.feature.mindrecord.data.dto.WeeklyReportDayDto
Expand All @@ -17,7 +18,9 @@ fun WeeklyReportDto.toDomain(): WeeklyReport =
diaryAmount = diaryAmount,
summaryText = summaryText,
week = week.map { it.toDomain() },
dailyQuestions = dailyQuestions.map { it.toDomain() },
// 날짜를 해석하지 못한 항목은 여기서 뺀다. 종전에는 집계 경로가 버리고 표시 경로가
// `LocalDate.now()` 로 메워, 같은 목록을 두고 한쪽은 제외·한쪽은 왜곡이었다 (#547).
dailyQuestions = dailyQuestions.mapNotNull { it.toDomainOrNull() },
emotions = emotions.map { it.toDomain() },
emotionAnalysis = emotionAnalysis?.toDomain(),
)
Expand Down Expand Up @@ -48,12 +51,26 @@ fun WeeklyReportDayDto.toDomain(): WeeklyReportDay =
emotion = emotion?.toDomain(),
)

fun WeeklyReportDailyQuestionDto.toDomain(): WeeklyReportDailyQuestion =
WeeklyReportDailyQuestion(
/**
* 날짜를 해석하지 못하면 null 을 돌려 목록에서 제외한다.
*
* 오늘로 메우지 않는다 — 그러면 파싱 못 한 기록이 **오늘 작성한 것처럼** HISTORY 카드에
* 앉아 필드 이상을 감춘다. 로그도 에러 표시도 없는 조용한 오표시였다 (#547).
*/
fun WeeklyReportDailyQuestionDto.toDomainOrNull(): WeeklyReportDailyQuestion? {
val parsedDate =
parseServerDateOrNull(date) ?: run {
Log.w(TAG, "주간리포트 데일리질문 날짜를 해석하지 못해 목록에서 제외한다: raw=$date")
return null
}
return WeeklyReportDailyQuestion(
title = title,
content = content,
date = date,
date = parsedDate,
)
}

private const val TAG = "WeeklyReportMapper"

fun EmotionAnalysisSummaryDto.toDomain(): EmotionAnalysis =
EmotionAnalysis(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.afternote.feature.mindrecord.data.mapper

import com.afternote.feature.mindrecord.data.dto.WeeklyReportDailyQuestionDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.LocalDate

/**
* 서버 날짜 해석과 실패 정책이 DTO→도메인 경계에 있다는 계약 (#547).
*
* 종전에는 이 지식이 `WeeklyReportViewModel` 에 있었고, 실패 처리도 소비처마다 갈렸다 —
* 집계 경로는 버리고 표시 경로는 `LocalDate.now()` 로 메워 파싱 못 한 기록이 **오늘
* 작성한 것처럼** 카드에 앉았다. 이제 한 곳에서 정한다: **해석 못 하면 제외.**
*/
class WeeklyReportDateMapperTest {
private fun dto(date: String) = WeeklyReportDailyQuestionDto(title = "질문", content = "답변", date = date)

@Test
fun `요일이 붙은 서버 기본 포맷을 해석한다`() {
// 실서버가 이 형태로 내려준다 (2026-08-23 실측).
assertEquals(LocalDate.of(2026, 8, 23), dto("2026.08.23 일").toDomainOrNull()?.date)
}

@Test
fun `ISO 날짜도 해석한다`() {
assertEquals(LocalDate.of(2026, 3, 21), dto("2026-03-21").toDomainOrNull()?.date)
}

@Test
fun `시각이 붙은 ISO 도 해석한다`() {
// ISO_DATE 만 두면 뒤가 남아 실패한다 — 세 포맷을 모두 허용하는 이유다.
assertEquals(LocalDate.of(2026, 3, 25), dto("2026-03-25T20:13:42").toDomainOrNull()?.date)
}

@Test
fun `해석하지 못한 날짜는 오늘로 메우지 않고 제외한다`() {
// 오늘로 메우면 그 기록이 오늘 작성한 것처럼 HISTORY 카드에 앉아 이상을 감춘다.
assertNull(dto("알 수 없음").toDomainOrNull())
assertNull(dto("").toDomainOrNull())
}

@Test
fun `제외돼도 나머지 항목은 살아남는다`() {
// 실패의 폭이 목록 전체가 되면 안 된다.
val kept =
listOf(dto("2026.08.23 일"), dto("깨진 값"), dto("2026-08-22"))
.mapNotNull { it.toDomainOrNull() }

assertEquals(
listOf(LocalDate.of(2026, 8, 23), LocalDate.of(2026, 8, 22)),
kept.map { it.date },
)
}

@Test
fun `제목과 본문은 그대로 통과한다`() {
val domain = dto("2026.08.23 일").toDomainOrNull()

assertEquals("질문", domain?.title)
assertEquals("답변", domain?.content)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.afternote.feature.mindrecord.domain.model

import java.time.LocalDate

data class WeeklyReport(
val dailyQuestionAmount: Int,
val diaryAmount: Int,
Expand Down Expand Up @@ -77,7 +79,14 @@ data class WeeklyReportDay(
data class WeeklyReportDailyQuestion(
val title: String,
val content: String,
val date: String,
/**
* 답변 날짜. **와이어 문자열이 아니라 해석된 값**이다 (#547).
*
* 서버 포맷(`yyyy.MM.dd 요일` / ISO) 해석은 data 계층 mapper 가 맡고, 해석하지 못한
* 항목은 그 경계에서 제외된다. 그래서 여기까지 올라온 값은 언제나 유효하고,
* 소비처마다 파싱을 다시 구현하거나 실패 폴백을 따로 정할 필요가 없다.
*/
val date: LocalDate,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[blocking] 파싱을 data 계층으로 올리면서 이 필드가 String → LocalDate 가 됐는데, :app 의 androidTest 픽스처가 아직 문자열을 넘깁니다. develop 병합으로 androidTest 컴파일 게이트가 들어오면서 CI 에 드러났습니다.

e: app/src/androidTest/.../MindRecordLifecycleAndroidTest.kt:239:74
     Argument type mismatch: actual type is 'String', but 'LocalDate' was expected.
e: app/src/androidTest/.../MindRecordLifecycleAndroidTest.kt:240:74   (같은 오류)
e: app/src/androidTest/.../TimeLetterMindRecordCompletionAndroidTest.kt:975:28  (같은 오류)
> Task :app:compileDebugAndroidTestKotlin FAILED

세 곳 모두 이미 LocalDate 를 손에 들고 있어서 .toString() 만 떼면 됩니다.

// 지금
WeeklyReportDailyQuestion("같은 날 질문", "답변 A", wednesday.toString())
// 고치면
WeeklyReportDailyQuestion("같은 날 질문", "답변 A", wednesday)

이 타입을 좁힌 것 자체는 좋은 변경입니다 — 문자열이었으면 이런 자리가 컴파일러에 안 걸리고 런타임까지 갔을 겁니다. 지금은 그 좁힘의 값을 치르는 자리이니 사용처만 데려가 주세요.

)

data class WeeklyReportEmotion(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,17 @@ fun HomeScreen(
TitleTopBar(
title = stringResource(MindRecordR.string.mindrecord_home_title),
actions = {
ViewModeSwitcher(
isListView = isListView,
image1 = R.drawable.core_ui_list,
image2 = R.drawable.core_ui_calendar,
onViewChange = { isListView = it },
)
// 주간리포트는 리스트/캘린더 두 보기가 없다 — 스위치를 눌러도 화면이
// 바뀌지 않으니 아예 노출하지 않는다. 컨트롤과 동작을 맞춘다 (#723).
// 같은 이유로 아래 FAB 도 이 탭에서 숨긴다.
if (selectedCategory != MindRecordCategoryUi.WeeklyReport) {
ViewModeSwitcher(
isListView = isListView,
image1 = R.drawable.core_ui_list,
image2 = R.drawable.core_ui_calendar,
onViewChange = { isListView = it },
)
}
},
)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
Expand Down Expand Up @@ -50,8 +51,11 @@ fun WeeklyReportScreen(
}

is WeeklyReportUiState.Error -> {
MindRecordErrorBox(
message = state.message.asString(),
// 보여 줄 리포트가 없어도 주차 선택과 재시도는 남긴다 — 둘 다 없으면 이 화면에서
// 빠져나갈 방법이 없다 (#723).
WeeklyReportErrorContent(
state = state,
onWeekSelect = viewModel::selectWeek,
onRetry = viewModel::retry,
modifier = modifier,
)
Expand All @@ -62,6 +66,7 @@ fun WeeklyReportScreen(
state = state,
onWeekSelect = viewModel::selectWeek,
onEmotionAnalysisRetry = viewModel::retryEmotionAnalysis,
onRetry = viewModel::retry,
modifier = modifier,
)
}
Expand All @@ -73,6 +78,7 @@ private fun WeeklyReportContent(
state: WeeklyReportUiState.Success,
onWeekSelect: (java.time.LocalDate) -> Unit,
onEmotionAnalysisRetry: () -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
// Figma 노드 852:11543 — main 컨테이너의 섹션 간 gap=32, 시작 pt=8, 끝 pb=200.
Expand All @@ -82,6 +88,15 @@ private fun WeeklyReportContent(
contentPadding = PaddingValues(top = 8.dp, bottom = 200.dp),
verticalArrangement = Arrangement.spacedBy(32.dp),
) {
state.loadFailure?.let { failure ->
item {
WeekLoadFailureBanner(
failure = failure,
onRetry = onRetry,
)
}
}

item {
WeeklyReportReviewCard(
selectedMonday = state.selectedMonday,
Expand Down Expand Up @@ -198,6 +213,82 @@ private fun ErrorBox(
}
}

/**
* 조회에 실패했지만 직전 리포트가 남아 있을 때의 배너 (#723).
*
* 어느 주차를 못 불렀는지 밝히고 재시도 수단을 준다. 화면 아래에는 직전에 성공한
* 리포트가 그대로 남아 있어 다른 주차로 옮길 수도 있다.
*/
@Composable
private fun WeekLoadFailureBanner(
failure: WeeklyReportUiState.LoadFailure,
onRetry: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text =
stringResource(
R.string.mindrecord_weekly_report_load_failed,
failure.failedWeekLabel.monthValue,
(failure.failedWeekLabel.dayOfMonth - 1) / 7 + 1,
),
style = AfternoteDesign.typography.bodySmallB,
color = AfternoteDesign.colors.gray9,
)
Text(
text = failure.message.asString(),
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.gray6,
)
TextButton(onClick = onRetry) {
Text(
text = stringResource(R.string.mindrecord_weekly_report_retry),
style = AfternoteDesign.typography.bodySmallB,
color = AfternoteDesign.colors.gray9,
)
}
}
}

/**
* 보여 줄 리포트가 하나도 없는 실패 화면 (#723).
*
* 오류 문구만 렌더하면 주차 선택 UI 까지 사라져 이 상태에서 빠져나갈 수 없다.
* 주차 드롭다운과 재시도를 함께 남긴다.
*/
@Composable
private fun WeeklyReportErrorContent(
state: WeeklyReportUiState.Error,
onWeekSelect: (java.time.LocalDate) -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
WeeklyReportReviewCard(
selectedMonday = state.failedMonday,
weekOptions = state.weekOptions,
onWeekSelect = onWeekSelect,
dateRange = "",
counts = emptyList(),
)
Text(
text = state.message.asString(),
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.gray6,
)
TextButton(onClick = onRetry) {
Text(
text = stringResource(R.string.mindrecord_weekly_report_retry),
style = AfternoteDesign.typography.bodySmallB,
color = AfternoteDesign.colors.gray9,
)
}
}
}

/**
* 감정 카드 본문 문구.
*
Expand Down Expand Up @@ -266,6 +357,7 @@ private fun WeeklyReportScreenPreview() {
),
onWeekSelect = {},
onEmotionAnalysisRetry = {},
onRetry = {},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,30 @@ sealed interface WeeklyReportUiState {
*/
val summaryText: String,
val dailyQuestions: List<DailyQuestion>,
/**
* 주차 조회에 실패했지만 **직전에 보던 리포트가 남아 있는** 상태 (#723).
*
* 실패해도 화면 전체를 오류로 바꾸지 않는다. 리포트와 주차 선택 UI 를 유지한 채
* 배너로만 알려야 사용자가 재시도하거나 다른 주차로 옮길 수 있다.
*/
val loadFailure: LoadFailure? = null,
) : WeeklyReportUiState

/** 화면을 유지한 채 알리는 조회 실패. [failedWeekLabel] 은 사용자가 고른 주의 월요일. */
data class LoadFailure(
val message: UiText,
val failedWeekLabel: LocalDate,
)

/**
* 보여 줄 리포트가 하나도 없는 실패 (첫 조회부터 실패).
*
* 이때도 [weekOptions] 와 [failedMonday] 를 함께 넘긴다 — 재시도와 주차 재선택 수단을
* 화면에 남겨야 복구가 가능하다. 종전에는 오류 문구 하나만 렌더해 둘 다 사라졌다 (#723).
*/
data class Error(
val message: UiText,
val weekOptions: List<WeekOption>,
val failedMonday: LocalDate,
) : WeeklyReportUiState
}
Loading
Loading