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
28 changes: 20 additions & 8 deletions docs/qa/assumptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,17 +267,29 @@ Compose 테스트로 고정했다.
스키마에 `date` 없음, 보내도 무시됨을 실측). 날짜 선택이 표시 전용인 문제는 BE 계약
확장이나 기획 결정이 필요해 #1008 로 분리했다.

## #1008 — 일기 기록 날짜를 표시 전용으로 (판단 근거)
## #1008 — 일기 기록 날짜 (해소, 추측 없음)

서버가 `date` 를 받지 않는다는 것은 추측이 아니라 실측이다 (2026-08-25).
**서버가 `date` 를 받는다.** 2026-08-25 실측 당시에는 아니었고, 그래서 #1121 이 날짜 행을
표시 전용으로 잠갔다. 그 뒤 BE 가 (a) 계약 확장을 했다.

- `POST /api/v1/diary` 에 `date: "2026-08-01"` → 저장분 `date: "2026-08-25"` (diaryId 41545)
- `PATCH /api/v1/diary/41545` 에 같은 값 → 저장분 그대로 `2026-08-25`
- Swagger 스키마의 요청 필드: `title · content · isDraft · todayMood · receiverIds` (양쪽 모두 `date` 없음)
| 시점 | 상태 |
|---|---|
| 2026-08-25 (FE 실측) | `POST`·`PATCH` 에 `date: "2026-08-01"` 을 실어도 저장분은 `2026-08-25`. Swagger 요청 스키마에 `date` 없음 |
| 2026-08-29 (`Afternote-BE#244` → PR #262 머지) | `DiaryCreateRequest.date`·`DiaryUpdateRequest.date` 신설. `Diary.entry_date` 컬럼과 `(user_id, entry_date)` 인덱스, 월 조회·주간 리포트가 `entry_date` 기준 |

계약 규칙도 서버 코드에서 그대로 읽었다.

- 생성: 미전송이면 오늘(Asia/Seoul)
- 수정: **생략이면 기존 값 유지**
- 공통: 미래 날짜는 `400`(code `2101`), 과거는 제한 없음

FE 는 그 계약대로 되돌렸다 — 날짜 행에 피커를 다시 붙이고, 고른 값을 요청에 싣는다.
수정 경로는 «화면 값이 서버에서 왔거나 사용자가 고른 것일 때만» 싣는다. 프리필이 실패한 채
오늘을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨지기 때문이다 — 수신자 목록을 빈 목록으로
보내면 안 되는 것과 같은 함정이다. `DiaryRecordDateWireTest` 가 다섯 갈래를 잠근다.

**추측인 부분은 «어느 쪽으로 맞출지» 다.** 이슈가 든 두 선택지 중 (b) 표시 전용을 택했다 —
(a) BE 확장은 우리 손 밖이고, 그때까지 «고를 수 있지만 반영되지 않는» 상태를 두면 사용자가
고른 날짜로 저장됐다고 믿는다. BE 가 `date` 를 수용하면 피커를 되돌린다.
**남은 것은 없다.** 시안(Figma 2671:17922)의 chevron 도 이제 코드와 같다 — #1121 이 그것을
뺐던 것이 의도적 이탈이었고, 그 이탈이 해소됐다.
## #613 — 수신자 홈 설정 진입 (판단 보류)

- 시안(수신자 섹션 `4327:73626`)의 수신자 홈 헤더에는 **톱니만** 있고 프로필 아이콘이 없다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ data class DiaryCreateRequestDto(
// 생성 API 에서 `null` 과 빈 목록은 모두 "수신자 없음" 으로 정규화되고, 작성 UI 는 항상
// 목록을 갖고 있다. 빈 목록을 그대로 보내면 되므로 nullable 로 낮추지 않는다 (#789).
@SerialName("receiverIds") val receiverIds: List<Long>,
/**
* 기록일 `yyyy-MM-dd`. 미전송이면 서버가 오늘(Asia/Seoul)로 채우고, **미래 날짜는
* 400(code 2101)** 이다 (`Afternote-BE#244`, PR #262). 작성 화면이 항상 값을 갖고
* 있어 nullable 로 낮추지 않는다 (#1008).
*/
@SerialName("date") val date: String,
)

@Serializable
Expand All @@ -40,6 +46,8 @@ data class DiaryUpdateRequestDto(
@SerialName("todayMood") val todayMood: TodayMoodDto,
/** null 이면 기존 수신자 유지, 빈 목록이면 전체 해제 (서버 규칙). */
@SerialName("receiverIds") val receiverIds: List<Long>? = null,
/** 기록일 `yyyy-MM-dd`. **null 이면 기존 기록일 유지** (서버 규칙). 미래 날짜는 400(code 2101). */
@SerialName("date") val date: String? = null,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ fun DiaryCreatePayload.toRequest(): DiaryCreateRequestDto =
isDraft = isDraft,
todayMood = todayMood.toDto(),
receiverIds = receiverIds,
// LocalDate.toString() 이 ISO-8601 `yyyy-MM-dd` 다 — 서버 계약과 같은 형식이다.
date = date.toString(),
)

fun DiaryUpdatePayload.toRequest(): DiaryUpdateRequestDto =
Expand All @@ -61,4 +63,5 @@ fun DiaryUpdatePayload.toRequest(): DiaryUpdateRequestDto =
isDraft = isDraft,
todayMood = todayMood.toDto(),
receiverIds = receiverIds,
date = date?.toString(),
)
Original file line number Diff line number Diff line change
Expand Up @@ -122,12 +122,46 @@ class MindRecordDtoContractTest {
isDraft = false,
todayMood = TodayMoodDto.SOSO,
receiverIds = emptyList(),
date = "2026-08-01",
),
)

assertEquals(true, encoded.contains("\"receiverIds\":[]"))
}

@Test
fun `생성은 기록일을 항상 싣고 수정은 생략할 수 있다`() {
// 서버는 생성에서 미전송이면 오늘로 채우고, 수정에서 생략이면 기존 값을 유지한다
// (Afternote-BE#244, PR #262). 그 차이가 타입에 그대로 드러나야 한다 (#1008).
val created =
json.encodeToString(
DiaryCreateRequestDto.serializer(),
DiaryCreateRequestDto(
title = "t",
content = "c",
isDraft = false,
todayMood = TodayMoodDto.SOSO,
receiverIds = emptyList(),
date = "2026-08-01",
),
)
assertEquals(true, created.contains("\"date\":\"2026-08-01\""))

val omitted =
json.encodeToString(
DiaryUpdateRequestDto.serializer(),
DiaryUpdateRequestDto(
title = "t",
content = "c",
isDraft = false,
todayMood = TodayMoodDto.SOSO,
receiverIds = null,
date = null,
),
)
assertEquals(false, omitted.contains("\"date\""))
}

@Test
fun `PATCH 는 생략과 명시적 null 이 코드에서 구분된다`() {
// 선택 필드라 nullable 은 유지하되 기본값은 없다 — 호출부가 모든 인자를 적어야 한다.
Expand Down
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

enum class TodayMood {
HAPPY,
SOSO,
Expand Down Expand Up @@ -38,6 +40,13 @@ data class DiaryCreatePayload(
val todayMood: TodayMood,
/** 이 일기를 전달받을 수신자 ID 목록. 미선택 시 빈 목록 — 서버가 "수신자 없음" 으로 본다. */
val receiverIds: List<Long>,
/**
* 사용자가 고른 기록일. 서버는 미전송 시 오늘(Asia/Seoul)로 채우지만 작성 화면은 항상
* 값을 갖고 있어 그대로 싣는다 (#1008).
*
* **미래 날짜는 서버가 400(code 2101)으로 거절한다.** 과거는 제한이 없다.
*/
val date: LocalDate,
)

data class DiaryUpdatePayload(
Expand All @@ -53,4 +62,12 @@ data class DiaryUpdatePayload(
* 기존 지정을 지워 버린다.
*/
val receiverIds: List<Long>? = null,
/**
* 기록일. **`null` 이면 서버가 기존 값을 유지한다** (`DiaryUpdateRequest.date` — 생략 시 유지).
*
* 화면에 뜬 날짜가 서버에서 온 것이거나 사용자가 직접 고른 것일 때만 싣는다. 프리필이
* 날짜를 못 가져온 채로 «오늘» 을 실어 보내면 기록일이 조용히 오늘로 옮겨진다 — 수신자
* 목록을 빈 목록으로 보내면 안 되는 것과 같은 종류의 함정이다 (#1008).
*/
val date: LocalDate? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,15 @@ class FakeDiaryRepository(
onUpdate?.let { return it(id, payload) }
diaries.replaceAll { diary ->
if (diary.diaryId == id) {
// date·imageUrl 은 수정 요청 계약에서 빠졌다 (#955) — 기존 값을 유지한다.
// imageUrl 은 수정 요청 계약에 없다 (#955) — 기존 값을 유지한다.
// date 는 2026-08-29 부터 계약에 있고 **생략(null)이면 기존 값 유지**다
// (Afternote-BE#244, PR #262). 서버와 같은 규칙으로 흉내 낸다 (#1008).
diary.copy(
title = payload.title,
content = payload.content,
todayMood = payload.todayMood,
isDraft = payload.isDraft,
date = payload.date?.toString() ?: diary.date,
)
} else {
diary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.afternote.core.ui.R
import com.afternote.core.ui.asString
import com.afternote.core.ui.calendar.BottomSheetCalendar
import com.afternote.core.ui.theme.AfternoteDesign
import com.afternote.core.ui.theme.AfternoteTheme
import com.afternote.core.ui.topbar.DetailTopBar
Expand All @@ -71,6 +72,7 @@ fun DiaryWriteScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showReceiverSheet by remember { mutableStateOf(false) }
var showDateSheet by remember { mutableStateOf(false) }
val currentOnSubmitSuccess by rememberUpdatedState(onSubmitSuccess)

LaunchedEffect(uiState.submitState) {
Expand All @@ -91,6 +93,7 @@ fun DiaryWriteScreen(
onContentChanged = viewModel::onContentChanged,
onMoodSelected = viewModel::onMoodSelected,
onReceiverRowClick = { showReceiverSheet = true },
onDateRowClick = { showDateSheet = true },
onMediaPicked = viewModel::uploadMedia,
)

Expand All @@ -106,6 +109,20 @@ fun DiaryWriteScreen(
onDismiss = { showReceiverSheet = false },
)
}

if (showDateSheet) {
BottomSheetCalendar(
title = stringResource(MindRecordR.string.mindrecord_write_date_picker_title),
initialDate = uiState.date,
// 미래 날짜는 서버가 400(code 2101)으로 거절한다. 시트는 core:ui 공용이라
// 달력에서 막지 못하므로 ViewModel 이 받아서 사유와 함께 되돌린다 (#1008).
onDateSelect = {
viewModel.onDateSelected(it)
showDateSheet = false
},
onDismiss = { showDateSheet = false },
)
}
}

/**
Expand All @@ -131,6 +148,7 @@ internal fun DiaryWriteScreenContent(
onContentChanged: (String) -> Unit,
onMoodSelected: (TodayMood) -> Unit,
onReceiverRowClick: () -> Unit,
onDateRowClick: () -> Unit,
onMediaPicked: suspend (String) -> String? = { null },
) {
Scaffold(
Expand Down Expand Up @@ -243,15 +261,15 @@ internal fun DiaryWriteScreenContent(
}

// Figma 2671:17922 — 날짜 선택 행 ("yyyy년 M월 d일" + 아래 화살표)
// **표시 전용이다.** 서버는 생성·수정 어느 쪽에서도 date 를 받지 않고 기록 날짜를
// 요청 시각으로 정한다 (실측 2026-08-25: POST·PATCH 에 date 를 실어도 저장분은
// 오늘, Swagger 스키마에도 필드가 없다). 고를 수 있게 두면 사용자는 고른 날짜로
// 남았다고 믿는데 실제로는 오늘로 저장된다 — 고르지 못하게 해 거짓을 없앤다 (#1008).
//
// BE 가 date 를 수용하면 이 자리에 피커를 되돌린다. 이어쓰기 프리필이 채워 주는
// 원래 날짜를 보여 주는 역할은 그대로다.
// 서버가 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받는다 (Afternote-BE#244, PR #262).
// 그 전까지는 고른 날짜가 요청에 실리지 않아 «고를 수 있지만 반영되지 않는» 상태였고,
// #1121 이 그 동안 이 행을 표시 전용으로 잠가 뒀다. 계약이 왔으므로 시안대로 되돌린다 (#1008).
Row(
modifier = Modifier.fillMaxWidth(),
modifier =
Modifier
.fillMaxWidth()
.clickable(role = Role.Button, onClick = onDateRowClick),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Expand All @@ -260,6 +278,12 @@ internal fun DiaryWriteScreenContent(
style = AfternoteDesign.typography.captionLargeR,
color = AfternoteDesign.colors.gray9,
)
Icon(
painter = painterResource(R.drawable.core_ui_arrowdown),
contentDescription = null,
tint = AfternoteDesign.colors.gray9,
modifier = Modifier.size(16.dp),
)
}

HorizontalDivider()
Expand Down Expand Up @@ -330,6 +354,7 @@ internal fun DiaryWriteScreenContent(

val errorMessage =
(uiState.submitState as? SubmitState.Failed)?.message?.asString()
?: uiState.dateError?.asString()
?: uiState.draftLoadError?.asString()
if (errorMessage != null) {
Text(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,23 @@ data class DiaryWriteUiState(
val content: String = "",
val mood: TodayMood? = null,
/**
* 화면에 표시할 기록 날짜. **표시 전용이다** — 서버는 생성·수정 어느 쪽에서도 이 값을
* 받지 않고 기록 날짜를 요청 시각으로 정한다 (#1008). 이어쓰기 프리필이 원래 날짜를
* 채워 주는 자리라 상태 자체는 남긴다.
* 화면에 표시하고 서버로 보낼 기록일. 사용자가 날짜 행에서 고른다 (#1008).
*
* 서버는 2026-08-29 부터 생성·수정 양쪽에서 `date` 를 받는다 (`Afternote-BE#244`, PR #262).
* 미래 날짜는 400(code 2101)이라 [dateError] 로 미리 막는다.
*/
val date: LocalDate = LocalDate.now(),
/**
* [date] 를 **수정 요청에 실어도 되는지.** 프리필이 서버 날짜를 준 뒤이거나 사용자가 직접
* 고른 뒤에만 true 다.
*
* 신규 작성은 언제나 싣는다(기본값이 오늘이고 그대로 저장되는 것이 맞다). 문제는 수정
* 경로다 — 프리필이 실패한 채 «오늘» 을 실어 보내면 기존 기록일이 조용히 오늘로 옮겨진다.
* 그럴 땐 키를 생략해 서버가 기존 값을 유지하게 한다 (#1008).
*/
val isDateChosen: Boolean = false,
/** 고를 수 없는 날짜를 짚었을 때의 안내. 서버가 400 을 주기 전에 화면에서 먼저 막는다 (#1008). */
val dateError: UiText? = null,
/** `GET /users/receivers` 로 불러온 내 수신인 목록. */
val receivers: List<Receiver> = emptyList(),
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ class DiaryWriteViewModel
_uiState.update { it.copy(content = value) }
}

/**
* 날짜 행에서 고른 기록일을 싣는다 (#1008).
*
* **미래 날짜는 받지 않는다.** 서버가 400(code 2101)으로 거절하므로, 상태에 넣으면
* 사용자는 저장을 눌러 봐야 실패를 안다. 고르는 순간 사유와 함께 막는다.
*/
fun onDateSelected(date: LocalDate) {
if (date.isAfter(LocalDate.now())) {

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.

[nit] 서버는 LocalDate.now(Asia/Seoul) 기준으로 미래를 판정하는데 여기는 기기 시간대입니다. 서울보다 앞선 시간대(UTC+10 이상)에서 자정 전후에 «오늘» 을 고르면 화면은 통과시키고 서버가 2101 을 줍니다. 기본값 DiaryWriteUiState.date 도 같습니다. 레포 전체가 LocalDate.now() 관례라 이 PR 의 회귀는 아니고, 2101 을 dateError 문구로 매핑하거나 ZoneId.of("Asia/Seoul") 로 고정하는 건 별건으로 두셔도 됩니다.

_uiState.update {
it.copy(dateError = UiText.Resource(R.string.mindrecord_error_diary_future_date))
}
return
}
_uiState.update { it.copy(date = date, isDateChosen = true, dateError = null) }
}

fun onMoodSelected(mood: TodayMood) {
_uiState.update { it.copy(mood = mood) }
}
Expand Down Expand Up @@ -186,6 +202,9 @@ class DiaryWriteViewModel
// 전체 해제로 읽어, 수신자를 건드리지 않은 편집이 기존 지정을
// 지운다 — 목록 응답에 수신자가 없어 되살릴 수도 없다.
receiverIds = state.selectedReceiverIds.toList().takeIf { it.isNotEmpty() },
// 화면 값이 서버에서 왔거나 사용자가 고른 것일 때만 싣는다.
// 그 밖에는 키를 생략해 서버가 기존 기록일을 유지한다 (#1008).
date = state.date.takeIf { state.isDateChosen },
),
)
} else {
Expand All @@ -196,6 +215,7 @@ class DiaryWriteViewModel
isDraft = isDraft,
todayMood = mood,
receiverIds = state.selectedReceiverIds.toList(),
date = state.date,
),
)
}
Expand Down Expand Up @@ -288,9 +308,11 @@ class DiaryWriteViewModel
title = draft.title,
content = draft.content,
mood = draft.todayMood,
// 표시 전용 값이다 — 서버가 날짜를 주면 그걸 보여 주고, 안 주면
// 화면에 이미 떠 있던 값을 유지한다. 고르는 수단은 없다 (#1008).
// 서버가 준 기록일을 그대로 보여 주고, 그때부터 수정 요청에도 싣는다.
// 프리필이 날짜를 못 주면 화면 값(오늘)을 유지하되 `isDateChosen` 은
// false 로 남겨, 수정이 기존 기록일을 오늘로 밀지 않게 한다 (#1008).
date = draft.toUi()?.date ?: it.date,
isDateChosen = draft.toUi()?.date != null || it.isDateChosen,
isDraftLoading = false,
draftLoaded = true,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
<string name="mindrecord_diary_write_mood_soso">보통</string>
<string name="mindrecord_diary_write_mood_sad">슬픔</string>
<string name="mindrecord_write_receiver_setting">수신자 설정하기</string>
<string name="mindrecord_write_date_picker_title">기록 날짜</string>
<string name="mindrecord_write_receiver_to">%s님에게</string>
<string name="mindrecord_write_receiver_to_multiple">%1$s님 외 %2$d명에게</string>
<string name="mindrecord_write_receiver_sheet_title">수신자 선택</string>
Expand Down Expand Up @@ -203,6 +204,7 @@
<string name="mindrecord_error_diary_list_failed">일기를 불러오지 못했습니다.</string>
<string name="mindrecord_error_diary_submit_failed">일기 등록에 실패했습니다.</string>
<string name="mindrecord_error_diary_draft_load_failed">임시 저장된 일기를 불러오지 못했습니다.</string>
<string name="mindrecord_error_diary_future_date">기록 날짜는 오늘까지만 고를 수 있습니다.</string>
<string name="mindrecord_error_daily_question_draft_load_failed">임시저장한 답변을 불러오지 못했어요. 이대로 저장하면 이전 임시저장이 덮어써집니다.</string>
<string name="mindrecord_error_daily_question_submit_failed">등록에 실패했습니다.</string>
<string name="mindrecord_error_daily_question_answer_required">답변을 입력해 주세요.</string>
Expand Down
Loading
Loading