diff --git a/apps/web/README.md b/apps/web/README.md index dedc096..179e6eb 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -95,10 +95,10 @@ await showChangeNotification({ | -------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | | A2 목록 | "확인할 변경"(대기)과 "확인한 변경"(답함, 대체됨), "전체"/"이슈 별로 보기" 탭, 빈 화면, 알림 꺼짐 안내, 불러오기 실패 이유 | `api.listMyChanges(me)` 한 번 (notify.list.one-call) | | A3 상세·답하기 | 결정 사항, 이전 값 → 지금 값, 이유, 출처 칩, 누가·언제, 내 이슈, 관계있다/없다 → 알았다/몰랐다 또는 이유(100자) | `api.answer(me, input)` (채널 `'app'`) | -| A4 답한 뒤 | 내 답과 답한 시각, 진행 3단계(새 변경 → 확인 완료 → 작업 반영), "작업 반영했어요" 체크("관계있다"로 답한 카드만) | `api.markReflected(me, id, reflected)` | +| A4 답한 뒤 | 내 답과 답한 시각, 진행 3단계(결정 사항 변경 → 확인 완료 → 이슈 반영), "이슈에 반영했어요" 체크("관계있다"로 답한 카드만) | `api.markReflected(me, id, reflected)` | | A1 OS 알림 | 창이 열려 있는 동안 새 대기 변경이 생기면 알림(제목 40자 이하), 누르면 그 변경의 상세 | `api.watchMyChanges(me, …)` → `showChangeNotification` | -- **화면 모양:** 신지님 프로토타입(https://suju0.github.io/haribo-web/)의 크기·여백·글자·색을 그대로 따라요. 값은 `src/ui/`와 각 화면 파일에 있고, 색은 `styles.css`의 TDS 색 변수예요. 알림 꺼짐 안내의 설정 방법 그림은 `public/notify-guide.svg`(프로토타입 그림)예요. +- **화면 모양:** 신지님 프로토타입(https://suju0.github.io/haribo-web/)의 크기·여백·글자·색을 그대로 따라요. 값은 `src/ui/`와 각 화면 파일에 있고, 색은 `styles.css`의 TDS 색 변수예요. 알림 꺼짐 안내의 설정 방법 그림은 `public/notify-step-1~3.svg`(프로토타입 그림)이고, 그림 아래 글자는 진짜 텍스트예요. - **내 이름:** 주소에 `?me=<이름>`(이슈 담당자 이름)을 붙여 한 번 열면 브라우저가 기억해요. 예시 데이터로 열면 이름 없이도 예시를 받는 사람(민수)으로 열려요. 이름을 고르는 화면은 없어요 (10/15 호윤). - **예시 데이터로 확인:** `pnpm app:web` → 데모 예시(`app.notify.title-length` 40자 → 32자) 카드의 "답하기"를 눌러 답하고 반영 체크까지 해 봐요. 답은 창 메모리에만 저장돼요 (새로 고치면 처음으로). - **목록은 터미널과 같은 함수로 만들어요:** `@sil/notify/browser`의 `loadNotifyData` + `buildPendingList`예요 (SIL-100). 자동 닫힘 숨기기, 답함·대체됨 7일 뒤 숨기기, 지난번 답 안내(`previousAnswer`)를 그 함수가 채워요. 예시 데이터도 같은 함수를 거쳐요 (날짜는 10/15로 고정). diff --git a/apps/web/public/notify-guide.svg b/apps/web/public/notify-guide.svg deleted file mode 100644 index 9058a56..0000000 --- a/apps/web/public/notify-guide.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/notify-step-1.svg b/apps/web/public/notify-step-1.svg new file mode 100644 index 0000000..c5cef5a --- /dev/null +++ b/apps/web/public/notify-step-1.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/notify-step-2.svg b/apps/web/public/notify-step-2.svg new file mode 100644 index 0000000..f5f75d2 --- /dev/null +++ b/apps/web/public/notify-step-2.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/notify-step-3.svg b/apps/web/public/notify-step-3.svg new file mode 100644 index 0000000..971297f --- /dev/null +++ b/apps/web/public/notify-step-3.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index e40ed26..c0facba 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -51,7 +51,7 @@ export type MyChangeStandard = PendingItemStandard; export type MyChangeIssue = PendingItemIssue; /** 내가 한 답 (A4). 대기·대체됨이면 MyChange.answer가 null이에요 */ export type MyChangeAnswer = PendingItemAnswer; -/** 같은 이슈에서 이전 버전에 한 마지막 답. "관계있다"였으면 "다시 고쳐야 해요" 안내를 붙여요 */ +/** 같은 이슈에서 이전 버전에 한 마지막 답. "관계있다"였으면 "최신 결정 사항을 반영해 주세요" 안내를 붙여요 (notify.pending.renotify-hint v2) */ export type MyChangePreviousAnswer = PreviousAnswer; /** 나에게 온 변경에 답하기. 답한 사람과 답한 곳('app')은 api가 채워요 */ @@ -69,7 +69,7 @@ export interface SilApi { listMyChanges(me: string): Promise; /** 답하기. 이미 닫혔으면 PENDING_CLOSED, 받은 사람이 아니면 FORBIDDEN, 이유가 비었거나 길면 VALIDATION_FAILED */ answer(me: string, input: MyAnswerInput): Promise; - /** 작업 반영함 켜기·끄기. 답한 변경에만 돼요. 풀어도 이슈를 다시 열지 않아요 (notify.check.reflected) */ + /** 이슈 반영함 켜기·끄기. 답한 변경에만 돼요. 풀어도 이슈를 다시 열지 않아요 (notify.check.reflected) */ markReflected( me: string, pendingCheckId: string, diff --git a/apps/web/src/screens/MyChangesScreen.tsx b/apps/web/src/screens/MyChangesScreen.tsx index 8632485..ae7e1da 100644 --- a/apps/web/src/screens/MyChangesScreen.tsx +++ b/apps/web/src/screens/MyChangesScreen.tsx @@ -175,8 +175,7 @@ function MyChanges(props: ScreenProps & { me: string }) { 나에게 온 변경 목록 - {props.me}님 이슈에 걸린 결정 사항이 바뀌면 여기에 와요. 확인한 - 변경은 7일 동안 보여요. + 답한 변경은 7일 뒤에 목록에서 내려가요 diff --git a/apps/web/src/screens/my-changes/Badges.tsx b/apps/web/src/screens/my-changes/Badges.tsx index 041c2c1..a0fd74f 100644 --- a/apps/web/src/screens/my-changes/Badges.tsx +++ b/apps/web/src/screens/my-changes/Badges.tsx @@ -1,5 +1,5 @@ /** - * 카드와 상세에 붙는 칩이에요: 상태, 새 결정 사항, 새 버전, 되돌림, 출처, PR에서 찾음, 완료된 이슈. + * 카드와 상세에 붙는 칩이에요: 상태, 새 결정 사항, 새 버전, 되돌림, 출처(PR에서 찾은 변경 포함), 완료된 이슈. * 색과 크기는 프로토타입(SIL-69)과 같아요. */ import type { MyChange } from '../../api/types.ts'; @@ -43,8 +43,8 @@ export function StatusChip(props: { change: MyChange }) { ); } -/** 새 결정 사항, 새 버전, 되돌림 */ -export function KindChips(props: { change: MyChange }) { +/** 새 결정 사항, 새 버전, 되돌림. 상세 제목 옆에서는 되돌림을 빼고 "현재 결정 사항" 상자에 붙여요 (신지님 시안 10/15) */ +export function KindChips(props: { change: MyChange; hideRevert?: boolean }) { const { change } = props; return ( <> @@ -58,7 +58,7 @@ export function KindChips(props: { change: MyChange }) { 새 버전 v{change.standard.version} )} - {change.standard.isRevert && ( + {change.standard.isRevert && !props.hideRevert && ( 되돌림 @@ -67,25 +67,16 @@ export function KindChips(props: { change: MyChange }) { ); } -/** 출처 칩과 "PR에서 찾음" (app.card.source-chip-only, app.card.found-in-pr-code-only) */ +/** + * 출처 칩 하나. 모두 같은 모양이고 AI 대화에만 반짝 아이콘을 붙여요 (신지님 시안 10/15) + * (app.card.source-chip-only, app.card.found-in-pr-code-only v2) + */ export function SourceChips(props: { change: MyChange }) { const { change } = props; + const isAiChat = change.standard.source === 'ai_chat' && !isFoundInPr(change); return ( - <> - {change.standard.source === 'ai_chat' ? ( - }> - {sourceChipLabel(change)} - - ) : ( - - {sourceChipLabel(change)} - - )} - {isFoundInPr(change) && ( - - PR에서 찾음 - - )} - + : undefined}> + {sourceChipLabel(change)} + ); } diff --git a/apps/web/src/screens/my-changes/ChangeDetail.tsx b/apps/web/src/screens/my-changes/ChangeDetail.tsx index 9ed4eed..8616a75 100644 --- a/apps/web/src/screens/my-changes/ChangeDetail.tsx +++ b/apps/web/src/screens/my-changes/ChangeDetail.tsx @@ -4,11 +4,11 @@ * - 답: 관계있다/관계없다 → 알았다/몰랐다(누르면 바로 저장) 또는 관계없는 이유(100자). 관계없다면 known을 보내지 않아요. * - 답할 때 에러: PENDING_CLOSED는 에러 없이 조용히 닫고(notify.answer.one-place), FORBIDDEN이면 답하기를 숨기고, * VALIDATION_FAILED는 입력란 아래에 보여 줘요 (타입명세서 11-4). - * - 진행 단계는 새 변경 → 확인 완료 → 작업 반영 세 개예요 (app.card.three-steps). - * - "작업 반영했어요"는 "관계있다"로 답한 카드에만, 답한 뒤에만 켜요. 풀어도 이슈를 다시 열지 않아요 (notify.check.reflected). + * - 진행 단계는 결정 사항 변경 → 확인 완료 → 이슈 반영 세 개예요 (app.card.three-steps v3). + * - "이슈에 반영했어요"는 "관계있다"로 답한 카드에만, 답한 뒤에만 켜요. 풀어도 이슈를 다시 열지 않아요 (notify.check.reflected). */ import { isSilError, MAX_UNAFFECTED_REASON_LENGTH } from '@sil/core'; -import { useRef, useState, type CSSProperties } from 'react'; +import { useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { api } from '../../api/index.ts'; import type { MyChange } from '../../api/types.ts'; import { CheckIcon, InfoIcon } from '../../ui/icons.tsx'; @@ -25,13 +25,14 @@ import { } from '../../ui/tds.tsx'; import { KindChips, SourceChips, StatusChip, statusLabel } from './Badges.tsx'; import { + changeLetter, findReplacingVersion, formatTime, - groupByIssue, + groupByChange, isNewStandard, needsRework, - RETURNED_TO_SEEN_TEXT, sameStandardVersions, + supersededText, } from './format.ts'; /** @@ -42,19 +43,28 @@ const QUESTIONS = { relation: '이 변경이 내 이슈와 관계가 있나요?', known: '이 변경을 이미 알고 있었나요?', }; -const REWORK_NOTICE = '지난번 변경을 반영하셨다면 다시 고쳐야 해요'; +/** 지난번에 "관계있다"로 답한 사람에게 붙이는 안내 (notify.pending.renotify-hint v2) */ +const REWORK_NOTICE = + '이전 결정 사항으로 작업 중이었다면 최신 결정 사항을 반영해 주세요'; +/** + * 대체된 변경에 붙이는 안내 (신지님 시안 10/15). + * 대체한 새 버전이 자동으로 닫혀 답할 카드가 없으면 목록과 같은 "이미 확인한 값으로 돌아왔어요"를 써요 (supersededText) + */ +const SUPERSEDED_NOTICE = + '확인할 변경이 새 버전으로 대체되었어요. 최신 버전의 확인할 변경에 답해 주세요.'; -const STEPS = ['새 변경', '확인 완료', '작업 반영']; +const STEPS = ['결정 사항 변경', '확인 완료', '이슈 반영']; const STEP_DESCRIPTIONS = [ '아직 확인하지 않은 변경이에요.', '담당자가 변경사항을 확인했다고 답한 상태예요.', - '실제 작업에 반영한 상태예요.', + '실제 이슈에 반영한 상태예요.', ]; /** 고르지 않은 큰 버튼 / 고른 큰 버튼 (프로토타입 값 그대로) */ const UNSELECTED_BUTTON: CSSProperties = { - background: 'rgba(222, 222, 255, 0.30)', - color: '#FFFFFF', + // 고르기 전에는 "현재 결정 사항" 상자와 같은 연한 파랑, 글자는 파랑 (신지님 시안 10/15) + background: C.blue50, + color: '#1F72EA', fontSize: 15, }; const SELECTED_BUTTON: CSSProperties = { fontSize: 15 }; @@ -111,38 +121,28 @@ export function ChangeDetail(props: { {standard.title} - + - {change.issue.status === 'done' && ( - - 완료된 이슈 - - )} - {isSuperseded && ( -
- - {replacingVersion === null - ? RETURNED_TO_SEEN_TEXT - : `새 버전(v${replacingVersion})으로 대체됐어요`} + {/* 바뀐 이유는 이슈 카드 안이 아니라 결정 사항 제목 바로 아래에 붙어요 (신지님 시안 10/15) */} +
+
+ + 바뀐 이유 -
- - {replacingVersion === null - ? '이 변경에는 답하지 않아도 돼요.' - : '답하기 전에 결정이 또 바뀌었어요. 이 변경에는 답하지 않아도 되고, 새 버전을 확인해주세요.'} - -
- )} + + {standard.reason} + +
@@ -156,24 +156,39 @@ export function ChangeDetail(props: { ) : ( <> + 되돌림 + + ) : undefined + } /> )}
{isSuperseded ? ( - replacing && ( - <> - - 지금 해야 할 일 - + // 대체된 변경 안내는 "지금 해야 할 일" 옆 노란 안내로 보여 줘요 (신지님 시안 10/15) + <> + + } + > + 지금 해야 할 일 + + {replacing && (
- - ) + )} + ) : change.answer ? ( <> 내 답변 @@ -204,8 +219,10 @@ export function ChangeDetail(props: { <> : undefined} + description="내 이슈가 바뀐 결정 사항에 영향을 받는지 답해주세요" + right={ + needsRework(change) ? : undefined + } > 지금 해야 할 일 @@ -245,7 +262,7 @@ function PeerTabs(props: { onBack: () => void; onOpen: (pendingCheckId: string) => void; }) { - const groups = groupByIssue(props.all); + const groups = groupByChange(props.all); const currentGroup = groups.find((group) => group.some((item) => item.pendingCheckId === props.change.pendingCheckId), ); @@ -288,20 +305,26 @@ function PeerTabs(props: { padding: '6px 8px 4px 0', }} > - {groups.map((group) => { + {groups.map((group, index) => { const first = group[0]!; + const label = `변경 ${changeLetter(index)}`; const isActive = group === currentGroup; const target = group.find((item) => item.status === 'pending') ?? first; const hasPending = group.some((item) => item.status === 'pending'); return ( )} @@ -885,11 +913,44 @@ function AnsweredBox(props: { {CHANNEL_LABELS[answer.channel]}에서 답함 {props.onNext && ( - - + // 화살표는 작게(12px), 글자와 같은 얇은 굵기로 (신지님 시안 10/15) + )} diff --git a/apps/web/src/screens/my-changes/ChangeList.tsx b/apps/web/src/screens/my-changes/ChangeList.tsx index 28a9518..621a726 100644 --- a/apps/web/src/screens/my-changes/ChangeList.tsx +++ b/apps/web/src/screens/my-changes/ChangeList.tsx @@ -1,11 +1,11 @@ /** * A2 목록이에요. "확인할 변경"(대기)과 "확인한 변경"(답함, 대체됨) 두 상자로 나눠요. - * 목록은 한 번 불러서 화면이 상태로 나눠요 (notify.list.one-call). "이슈 별로 보기"는 이슈 번호로 묶어요. + * 목록은 한 번 불러서 화면이 상태로 나눠요 (notify.list.one-call). "이슈별"은 이슈 번호로 묶어요. * 상자·줄·글자 값은 프로토타입(SIL-69)과 같아요. */ import type { CSSProperties, ReactNode } from 'react'; import type { MyChange } from '../../api/types.ts'; -import { C, ListRow, Result, Text } from '../../ui/tds.tsx'; +import { C, Chip, ListRow, Result, Text } from '../../ui/tds.tsx'; import { KindChips, SourceChips, StatusCell } from './Badges.tsx'; import { findReplacingVersion, @@ -50,7 +50,7 @@ export function ChangeList(props: {
)} @@ -130,20 +130,20 @@ function EmptyBoxes() { {emptyBox( C.blue100, OPEN_TITLE, - `아직 ${OPEN_TITLE}이 없어요`, - '새로운 변경이 감지되면 이곳에서 알려드릴게요', + `지금 ${OPEN_TITLE}이 없어요`, + '새로운 변경이 감지되면 이곳에서 알려 드릴게요', )} {emptyBox( C.grey200, CLOSED_TITLE, - `아직 ${CLOSED_TITLE}이 없어요`, + `지금 ${CLOSED_TITLE}이 없어요`, '변경에 답하면 이곳에 모여요', )} ); } -/** 이슈 별로 보기: 이슈마다 상자 하나. 답할 변경이 있는 이슈가 먼저, 상자 안은 높은 버전부터 */ +/** 이슈별: 이슈마다 상자 하나. 답할 변경이 있는 이슈가 먼저, 상자 안은 높은 버전부터 */ function IssueGroups(props: { changes: MyChange[]; onOpen: (pendingCheckId: string) => void; @@ -236,7 +236,8 @@ function ChangeRow(props: { {isPending && needsRework(change) && ( - 지난번 변경을 반영하셨다면 다시 고쳐야 해요 + 이전 결정 사항으로 작업 중이었다면 최신 결정 사항을 반영해 + 주세요 )} {isSuperseded && ( @@ -296,37 +297,37 @@ function TitleLine(props: { change: MyChange }) { > + ); } -/** 이전 값 → 지금 값. 되돌림이면 내가 답했던 버전을 함께 적어요 */ +/** + * 이전 값 → 지금 값. 이전 값만 더 연한 회색이에요 (신지님 시안 10/15). + * 되돌림 줄의 "내가 v2에 답한 이력이 있어요"는 시안에서 숨겼어요. + */ function ValueLine(props: { change: MyChange }) { - const { standard, previousAnswer } = props.change; - const values = ( + const { standard } = props.change; + return ( - {standard.previousValue === null - ? '새 결정 사항' - : `${standard.previousValue} (v${standard.previousVersion})`}{' '} + + {standard.previousValue === null + ? '새 결정 사항' + : `${standard.previousValue} (v${standard.previousVersion})`} + {' '} → {standard.value} (v{standard.version}) ); - if (!standard.isRevert || !previousAnswer) return values; +} + +/** 완료된 이슈면 제목 옆 칩들 맨 끝에 [완료] (신지님 시안 10/15) */ +function DoneIssueChip(props: { change: MyChange }) { + if (props.change.issue.status !== 'done') return null; return ( -
- {values} - - · 내가 v{previousAnswer.version}에 답한 이력이 있어요 - -
+ + 완료 + ); } @@ -348,7 +349,7 @@ function ReworkNote(props: { children: ReactNode }) { ); } -/** #5 · 이슈 제목 (완료된 이슈) · 바꾼 사람 · 시각 */ +/** #5 이슈 제목 바꾼 사람 시각. 가운뎃점 없이 띄어 써요 (신지님 시안 10/15) */ function MetaLine(props: { change: MyChange }) { const { issue, standard } = props.change; return ( @@ -356,7 +357,7 @@ function MetaLine(props: { change: MyChange }) { style={{ display: 'flex', alignItems: 'center', - gap: 4, + gap: 8, minWidth: 0, color: C.grey500, }} @@ -366,13 +367,12 @@ function MetaLine(props: { change: MyChange }) {
- · {issue.title} - {issue.status === 'done' ? ' (완료된 이슈)' : ''} + {issue.title}
- · {standard.changedBy ?? '모름'} · {formatTime(standard.changedAt)} + {standard.changedBy ?? '모름'} {formatTime(standard.changedAt)}
diff --git a/apps/web/src/screens/my-changes/NotificationBanner.tsx b/apps/web/src/screens/my-changes/NotificationBanner.tsx index 6a2bc14..df32611 100644 --- a/apps/web/src/screens/my-changes/NotificationBanner.tsx +++ b/apps/web/src/screens/my-changes/NotificationBanner.tsx @@ -17,16 +17,29 @@ import { C, Text } from '../../ui/tds.tsx'; /** 펼침 애니메이션 시간 (styles.css .notify-panel과 같아요) */ const PANEL_MS = 420; -/** 안내 그림(public/notify-guide.svg) 한 장에서 세 칸을 잘라 보여 줘요 */ -const GUIDE_COLUMNS = [ - { x: 20, width: 240 }, - { x: 324, width: 240 }, - { x: 630, width: 220 }, -]; +/** + * 설정 방법 3단계: 그림(public/notify-step-1~3.svg, 240 × 100)과 그 아래 진짜 글자예요 (신지님 시안 10/15). + * 글자 크기는 안내 머리글과 같아요: 제목 t6 굵게, 설명 st12 + */ const GUIDE_STEPS = [ - '사이트 정보 아이콘을 클릭. 브라우저에서 주소창 왼쪽의 자물쇠 또는 사이트 정보 아이콘을 클릭합니다', - '알림 권한을 허용으로 변경하세요. 사이트 설정 메뉴에서 알림 항목을 찾아 허용으로 변경합니다', - '이제 알림을 받을 수 있어요. 설정 변경 후 이 화면으로 돌아와 권한을 다시 확인해 주세요', + { + image: 'notify-step-1.svg', + title: '사이트 정보 아이콘을 클릭', + lines: [ + '브라우저에서 주소창 왼쪽의', + '자물쇠 또는 사이트 정보 아이콘을 클릭해요', + ], + }, + { + image: 'notify-step-2.svg', + title: '알림 권한을 허용으로 변경하세요', + lines: ['사이트 설정 메뉴에서', '알림 항목을 찾아 허용으로 변경해요'], + }, + { + image: 'notify-step-3.svg', + title: '이제 알림을 받을 수 있어요', + lines: ['설정 변경 후 이 화면으로 돌아와', '권한을 다시 확인해 주세요'], + }, ]; const DESCRIPTIONS: Record< @@ -212,39 +225,49 @@ function Banner(props: { >
- {GUIDE_COLUMNS.map((column) => ( -
+ {GUIDE_STEPS.map((step) => ( +
+
- + + {step.title} + + + {step.lines.map((line) => ( + + {line} + + ))} +
))}
-
    - {GUIDE_STEPS.map((step) => ( -
  1. {step}
  2. - ))} -
)} diff --git a/apps/web/src/screens/my-changes/format.test.ts b/apps/web/src/screens/my-changes/format.test.ts index 3bc8e48..d49ec15 100644 --- a/apps/web/src/screens/my-changes/format.test.ts +++ b/apps/web/src/screens/my-changes/format.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest'; import { MOCK_ME, mockApi } from '../../api/mock.ts'; import { + changeLetter, findReplacingVersion, + groupByChange, groupByIssue, isFoundInPr, isNewStandard, @@ -40,13 +42,12 @@ describe('예시 목록', () => { }); describe('카드 표시', () => { - it('PR에서 찾음은 AI가 찾았고 출처가 코드일 때만 (D61)', () => { + it('PR에서 찾은 변경은 AI가 찾았고 출처가 코드일 때만 (D61)', () => { expect(isFoundInPr(byId('inbox-coupon-v2'))).toBe(true); + expect(sourceChipLabel(byId('inbox-coupon-v2'))).toBe('PR에서 찾은 변경'); // T1(AI 대화)은 AI가 먼저 알아챘어도 "AI 대화" 칩만 expect(isFoundInPr(byId('inbox-title-length-v2'))).toBe(false); - expect(sourceChipLabel(byId('inbox-title-length-v2'))).toBe( - 'AI 대화에서 바꿈', - ); + expect(sourceChipLabel(byId('inbox-title-length-v2'))).toBe('AI 대화'); }); it('출처가 위키면 "위키"예요 (app.card.source-chip-only)', () => { @@ -56,7 +57,7 @@ describe('카드 표시', () => { ...wiki, standard: { ...wiki.standard, source: 'wiki' }, }), - ).toBe('위키에서 바꿈'); + ).toBe('위키'); }); it('이전 값이 없으면 새 결정 사항', () => { @@ -126,3 +127,23 @@ describe('이슈 별로 묶기', () => { ).toHaveLength(1); }); }); + +describe('상세 위 칩 (app.detail.change-letter)', () => { + it('결정 사항과 이슈 한 쌍마다 칩 하나, 먼저 온 쌍이 앞이에요', () => { + const groups = groupByChange(changes); + for (const group of groups) { + expect(new Set(group.map((item) => item.issue.key)).size).toBe(1); + expect(new Set(group.map((item) => item.standard.id)).size).toBe(1); + } + const firstArrived = groups.map((group) => + Math.min(...group.map((item) => Date.parse(item.createdAt))), + ); + expect(firstArrived).toEqual([...firstArrived].sort((a, b) => a - b)); + }); + + it('글자는 A부터, Z 다음은 AA예요', () => { + expect(changeLetter(0)).toBe('A'); + expect(changeLetter(25)).toBe('Z'); + expect(changeLetter(26)).toBe('AA'); + }); +}); diff --git a/apps/web/src/screens/my-changes/format.ts b/apps/web/src/screens/my-changes/format.ts index 1aa0a99..f478499 100644 --- a/apps/web/src/screens/my-changes/format.ts +++ b/apps/web/src/screens/my-changes/format.ts @@ -32,12 +32,17 @@ export function formatTime(iso: string, now = new Date()): string { : `${dateFormat.format(date)} ${time}`; } -/** 출처 칩 글자. 예: "AI 대화에서 바꿈" (app.card.source-chip-only) */ +/** + * 출처 칩 글자. 칩은 하나만 붙여요. 예: "AI 대화", "코드" (app.card.source-chip-only) + * AI가 찾았고 출처가 코드면 "PR에서 찾은 변경" (app.card.found-in-pr-code-only v2) + */ export function sourceChipLabel(change: MyChange): string { - return `${SOURCE_CHIP_LABELS[change.standard.source]}에서 바꿈`; + return isFoundInPr(change) + ? 'PR에서 찾은 변경' + : SOURCE_CHIP_LABELS[change.standard.source]; } -/** "PR에서 찾음"은 AI가 찾았고 출처가 코드일 때만 (app.card.found-in-pr-code-only, D61) */ +/** AI가 찾았고 출처가 코드일 때만 PR에서 찾은 변경이에요 (app.card.found-in-pr-code-only, D61) */ export function isFoundInPr(change: MyChange): boolean { return change.standard.aiDetected && change.standard.source === 'code'; } @@ -124,6 +129,36 @@ export function groupByIssue(items: MyChange[]): MyChange[][] { ); } +/** + * 상세 위 칩: 결정 사항과 내 이슈 한 쌍마다 하나예요. 받은 순서(그 쌍에 처음 온 시각)대로 놓아서 + * 답하거나 새 변경이 와도 이미 붙은 글자가 바뀌지 않아요. 새 쌍은 맨 뒤예요 (app.detail.change-letter) + */ +export function groupByChange(items: MyChange[]): MyChange[][] { + const groups = new Map(); + for (const item of items) { + const key = `${item.issue.key}\u0000${item.standard.id}`; + groups.set(key, [...(groups.get(key) ?? []), item]); + } + const firstArrived = (group: MyChange[]) => + Math.min(...group.map((item) => Date.parse(item.createdAt))); + return [...groups.values()] + .map((group) => + [...group].sort((a, b) => b.standard.version - a.standard.version), + ) + .sort((a, b) => firstArrived(a) - firstArrived(b)); +} + +/** 0 → "A", 25 → "Z", 26 → "AA" (app.detail.change-letter) */ +export function changeLetter(index: number): string { + let rest = index; + let letters = ''; + do { + letters = String.fromCharCode(65 + (rest % 26)) + letters; + rest = Math.floor(rest / 26) - 1; + } while (rest >= 0); + return letters; +} + /** 상세 위 버전 탭: 같은 이슈에서 같은 결정 사항의 버전만, 높은 버전부터. 하나뿐이면 탭을 그리지 않아요 */ export function sameStandardVersions( change: MyChange, diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8c2fa96..d5a75e0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -238,7 +238,10 @@ button { flex: 1 1 0; min-width: 260px; display: flex; - justify-content: center; + flex-direction: column; + align-items: center; + padding: 22px 16px 28px; + box-sizing: border-box; background: #fafafa; border-radius: 15px; } diff --git a/apps/web/src/ui/tds.tsx b/apps/web/src/ui/tds.tsx index 6fe8762..f869cc7 100644 --- a/apps/web/src/ui/tds.tsx +++ b/apps/web/src/ui/tds.tsx @@ -304,7 +304,7 @@ export function TextButton(props: { ); } -/** 두세 개 중 하나를 고르는 칸 (전체 / 이슈 별로 보기) */ +/** 두세 개 중 하나를 고르는 칸 (전체 / 이슈별) */ export function Segmented(props: { label: string; value: T; @@ -681,7 +681,11 @@ export function SectionTitle(props: { }} > {texts} - {props.right &&
{props.right}
} + {props.right && ( +
+ {props.right} +
+ )} ); } diff --git "a/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" "b/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" index d33c7f4..e764f63 100644 --- "a/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" +++ "b/docs/\355\203\200\354\236\205\353\252\205\354\204\270\354\204\234.md" @@ -103,23 +103,23 @@ ### `StandardVersion` — 기준 버전 (바뀔 때마다 한 줄) -| 칸 | 타입 | 비어 있나 | DB 칸 | 뜻 | -| ------------------- | ----------------------- | --------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `standardId` | `StandardId` | 아니요 | `standard_id` | | -| `version` | `number` | 아니요 | `version` | 1부터 하나씩 올라가요 | -| `title` | `string` | 아니요 | `title` | 버전마다 남겨요 (SIL-57). 40자를 넘으면 경고만 해요 (2절) | -| `value` | `string` | 아니요 | `value` | 기준 내용 한 문장 (화면 기준 120자, 2절) | -| `reason` | `string` | 아니요 | `reason` | 없으면 저장을 거절해요 | -| `alwaysOn` | `boolean` | 아니요 | `always_on` | **그 버전의** 항상 적용. 되돌리면 그 버전의 값으로 돌아가요 (2026-10-10 추가) | -| `evidence` | `string \| null` | 예 | `evidence` | 근거. 선택이에요(PR에서 찾은 변경은 필수). 있으면 저장하고 알림 카드에는 보여 주지 않아요 | -| `scope` | `StandardScope \| null` | 예 | `scope` | 적용 범위. 저장만 해요 | -| `changedBy` | `PersonRef \| null` | 예 | `changed_by` | 누가 바꿨나. MCP로 저장하면 그 세션의 사용자 이름이 들어가요 | -| `changedByActor` | `Actor` | 아니요 | `changed_by_actor` | AI가 저장했으면 `'ai'` | -| `aiDetected` | `boolean` | 아니요 | `ai_detected` | AI가 먼저 알아챈 변경이면 `true`: PR에서 찾음(출처 `code`) 또는 작업 중에 먼저 물은 T1(출처 `ai_chat`). 화면의 "PR에서 찾음"은 출처가 `code`일 때만 (D61) | -| `isRevert` | `boolean` | 아니요 | `is_revert` | 앞선 버전 값과 같아졌으면 `true` | -| `revertedToVersion` | `number \| null` | 예 | `reverted_to_version` | 되돌림이면 어느 버전으로 돌아갔는지 | -| `source` | `StandardSource` | 아니요 | `source` | 출처 | -| `createdAt` | `IsoDateTime` | 아니요 | `created_at` | 저장한 시각 | +| 칸 | 타입 | 비어 있나 | DB 칸 | 뜻 | +| ------------------- | ----------------------- | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `standardId` | `StandardId` | 아니요 | `standard_id` | | +| `version` | `number` | 아니요 | `version` | 1부터 하나씩 올라가요 | +| `title` | `string` | 아니요 | `title` | 버전마다 남겨요 (SIL-57). 40자를 넘으면 경고만 해요 (2절) | +| `value` | `string` | 아니요 | `value` | 기준 내용 한 문장 (화면 기준 120자, 2절) | +| `reason` | `string` | 아니요 | `reason` | 없으면 저장을 거절해요 | +| `alwaysOn` | `boolean` | 아니요 | `always_on` | **그 버전의** 항상 적용. 되돌리면 그 버전의 값으로 돌아가요 (2026-10-10 추가) | +| `evidence` | `string \| null` | 예 | `evidence` | 근거. 선택이에요(PR에서 찾은 변경은 필수). 있으면 저장하고 알림 카드에는 보여 주지 않아요 | +| `scope` | `StandardScope \| null` | 예 | `scope` | 적용 범위. 저장만 해요 | +| `changedBy` | `PersonRef \| null` | 예 | `changed_by` | 누가 바꿨나. MCP로 저장하면 그 세션의 사용자 이름이 들어가요 | +| `changedByActor` | `Actor` | 아니요 | `changed_by_actor` | AI가 저장했으면 `'ai'` | +| `aiDetected` | `boolean` | 아니요 | `ai_detected` | AI가 먼저 알아챈 변경이면 `true`: PR에서 찾음(출처 `code`) 또는 작업 중에 먼저 물은 T1(출처 `ai_chat`). 화면의 출처 칩 "PR에서 찾은 변경"은 출처가 `code`일 때만 (D61) | +| `isRevert` | `boolean` | 아니요 | `is_revert` | 앞선 버전 값과 같아졌으면 `true` | +| `revertedToVersion` | `number \| null` | 예 | `reverted_to_version` | 되돌림이면 어느 버전으로 돌아갔는지 | +| `source` | `StandardSource` | 아니요 | `source` | 출처 | +| `createdAt` | `IsoDateTime` | 아니요 | `created_at` | 저장한 시각 | - `StandardScope` = `{ code: string[]; api: string[]; screen: string[]; doc: string[] }`. 칸이 빠진 채로 저장하면 빈 목록으로 채워요. - `StandardSource` = `'code' | 'ai_chat' | 'figma' | 'meeting' | 'slack' | 'wiki'` @@ -492,8 +492,8 @@ try { | 이전 값 → 지금 값 | 이전 값(아래 규칙), `StandardVersion.value` | 이전 값이 `null`이면 "새 결정 사항" | | 이유 | `StandardVersion.reason` | 그대로 | | 누가 바꿨나 | `StandardVersion.changedBy` | 이름만 | -| AI가 찾았나 | `StandardVersion.aiDetected` | `true`이고 출처가 `code`일 때만 "PR에서 찾음" (D61) | -| 출처 | `StandardVersion.source` | `SOURCE_LABELS`로 "AI 대화에서 바꿈" / "코드에서 바꿈" | +| AI가 찾았나 | `StandardVersion.aiDetected` | `true`이고 출처가 `code`일 때만 출처 칩이 "PR에서 찾은 변경" (D61) | +| 출처 | `StandardVersion.source` | `SOURCE_CHIP_LABELS`로 "AI 대화" / "코드" 등, 칩은 하나만 | | 언제 | `StandardVersion.createdAt` | 오늘이면 "오후 2:13", 아니면 "10월 13일 오후 2:13" (보는 사람의 시간대) | | 내 이슈 | `Issue.key`, `Issue.title` | 제목이 길면 끝을 줄여요(…) | | 이슈 상태 | `Issue.status` | `'done'`일 때만 "완료" | @@ -534,8 +534,8 @@ try { | `pendingCheckId` | `string` | `PendingCheck.id` | | `status` | `'pending' \| 'answered' \| 'superseded'` | 자동 닫힘은 목록에 없어요 (notify.pending.hide-auto-closed) | | `createdAt` | `string` | 진행 단계 "새 변경" | -| `reflected` | `boolean` | 작업 반영함 (notify.check.reflected) | -| `reflectedAt` | `string \| null` | 진행 단계 "작업 반영" | +| `reflected` | `boolean` | 이슈 반영함 (notify.check.reflected) | +| `reflectedAt` | `string \| null` | 진행 단계 "이슈 반영" | | `standard` | `MyChangeStandard` | 확인할 그 버전: `id`, `title`, `version`, `value`, `previousVersion`, `previousValue`(11-4 "이전 값 고르기"), `reason`, `changedBy`, `changedAt`, `source`, `aiDetected`, `isRevert` | | `issue` | `MyChangeIssue` | 내 이슈: `key`, `title`, `status` | | `answer` | `MyChangeAnswer` 또는 `null` | 내 답: `affected`, `known`, `unaffectedReason`, `channel`, `answeredAt`. 대기·대체됨이면 `null` | @@ -551,7 +551,7 @@ try { | `watchMyChanges(me, onChange, onError)` | `watchPendingChecks`(`@sil/core/data`, Realtime) 구독. 돌려준 함수로 끊어요 (app.notify.realtime) | - **내 이름(`me`)**: 주소에 `?me=<이름>`을 붙여 열면 브라우저에 저장하고 주소에서 지워요. 다음부터는 저장한 이름을 써요. 이름을 고르는 화면은 만들지 않아요 (10/15 호윤, `apps/web/src/me.ts`). 예시 데이터로 열면 이름이 없어도 예시를 받는 사람(`INBOX_RECIPIENT`)으로 열려요. -- **예시 데이터**: `@sil/core`의 `inboxIssues`, `inboxVersions`, `inboxPendingChecks`, `inboxAnswers`예요. 화면의 상태(사람이 바꾼 결정 사항, PR에서 찾음, 새 결정 사항, 대체됨, 되돌림, 답함)를 모두 담았어요. +- **예시 데이터**: `@sil/core`의 `inboxIssues`, `inboxVersions`, `inboxPendingChecks`, `inboxAnswers`예요. 화면의 상태(사람이 바꾼 결정 사항, PR에서 찾은 변경, 새 결정 사항, 대체됨, 되돌림, 답함)를 모두 담았어요. - **대체한 버전 번호**: 서버가 알려 주지 않아서 같은 목록에서 같은 결정 사항·같은 이슈의 더 높은 버전 중 가장 가까운 것을 찾아요. 못 찾으면 대체한 버전이 자동으로 닫힌 것이라(내가 이미 확인한 값으로 되돌려짐) "새 버전으로 대체됐다가, 내가 이미 확인한 값으로 돌아왔어요"라고 써요. - **OS 알림(A1)**: 창이 열려 있는 동안 구독으로 목록을 다시 읽고, 처음 불러온 뒤 새로 생긴 대기 변경만 `showChangeNotification`으로 띄워요. 제목은 40자 이하로 줄여요 (app.notify.title-length). diff --git a/team-wiki/CHANGELOG.md b/team-wiki/CHANGELOG.md index 3e4a0e1..bc758eb 100644 --- a/team-wiki/CHANGELOG.md +++ b/team-wiki/CHANGELOG.md @@ -169,3 +169,7 @@ 2026-10-15 15:42 | 호윤 | app.notify.title-length 3→4 | 운영체제 알림 창에 뜨는 제목은 공백 포함 30자 이하로 쓴다 → 운영체제 알림 창에 뜨는 제목은 공백 포함 40자 이하로 쓴다 | 웹 앱 알림 시험(v2, v3)을 마치고 원래 값인 40자로 되돌린다 (v1로 되돌림) | AI 대화 2026-10-15 18:25 | 호윤 | copy.term.this-tool 1→2 | 임시 서비스 이름이 정해지기 전까지 문서와 화면에서는 이 도구라고 부르고 예전 가제는 쓰지 않는다 → 문서와 화면에서 서비스 이름은 BANDIT이라고 쓰고 예전 가제 HARIBO는 쓰지 않는다 | 서비스 이름이 HARIBO에서 BANDIT으로 정해졌다 | AI 대화 2026-10-15 18:57 | 민수 | notify.terminal.new-after-answered 1→2 | 같은 세션에서 모두 답한 뒤 새 확인할 변경이 생기면 상세와 질문을 바로 띄우지 않고, 작업을 입력할 때마다 남은 건수와 "결정 사항 확인"을 알리는 한 줄만 보여 준다 → 같은 세션에서 모두 답한 뒤 새 확인할 변경이 생기면 상세와 질문을 바로 띄우지 않고, Claude가 작업을 마칠 때마다 입력 칸 위에 남은 건수와 "결정 사항 확인"을 알리는 한 줄만 보여 준다 | 작업을 입력할 때 띄우면 한 줄이 작업 결과에 밀려 올라가서 못 보기 때문에, 부건님 v2 시안대로 작업을 마친 뒤 입력 칸 바로 위에 띄운다 | AI 대화 +2026-10-15 23:04 | 호윤 | app.card.three-steps 2→3 | 카드의 진행 단계는 새 변경, 확인 완료, 이슈 반영 세 개만 보여 주고 열어봄과 처리 완료는 만들지 않는다 → 카드의 진행 단계는 결정 사항 변경, 확인 완료, 이슈 반영 세 개만 보여 주고 열어봄과 처리 완료는 만들지 않는다 | 신지님 시안(10/15)에서 첫 단계 이름을 결정 사항 변경으로 바꿨다 | AI 대화 +2026-10-15 23:06 | 호윤 | app.card.found-in-pr-code-only 1→2 | 카드의 "PR에서 찾음" 표시는 AI가 찾았고 출처가 코드인 결정 사항에만 붙인다 → 카드의 출처 칩은 AI가 찾았고 출처가 코드인 결정 사항에만 "PR에서 찾은 변경"으로 보여 주고 따로 칩을 더 붙이지 않는다 | 신지님 시안(10/15)에서 PR에서 찾음 칩을 출처 칩 하나로 합쳤다 | AI 대화 +2026-10-15 23:08 | 호윤 | notify.pending.renotify-hint 1→2 | 같은 결정 사항이 다시 바뀌면 지난번에 관계있다로 답한 사람에게만 "지난번 변경(이전 값)을 반영하셨다면 다시 고쳐야 해요." 안내를 붙인다 → 같은 결정 사항이 다시 바뀌면 지난번에 관계있다로 답한 사람에게만 "이전 결정 사항으로 작업 중이었다면 최신 결정 사항을 반영해 주세요" 안내를 붙인다 | 신지님 시안(10/15)에서 안내 문구를 이전 결정 사항으로 작업 중이었다면 최신 결정 사항을 반영해 주세요로 바꿨다 | AI 대화 +2026-10-15 23:39 | 호윤 | app.detail.change-letter new→1 | (없음) → 변경 상세 위 칩은 결정 사항과 내 이슈 한 쌍마다 하나씩 두고 받은 순서대로 변경 A, 변경 B처럼 부른다 | 칩 하나가 결정 사항 하나를 가리키는데 결정 사항 ID는 칩에 넣기에 너무 길고 결정 사항에 정해진 번호가 없다 | AI 대화 diff --git a/team-wiki/INDEX.md b/team-wiki/INDEX.md index 4cd8b48..1169c03 100644 --- a/team-wiki/INDEX.md +++ b/team-wiki/INDEX.md @@ -24,7 +24,7 @@ | wiki | 기능-기준모으기.md | 기준을 저장·조회·되돌리는 규칙과 MCP 도구 | 13 | | detect | 기능-바뀐기준찾기.md | 반영된 PR을 보고 바뀐 기준을 찾는 방법과 안전 규칙 | 16 | | notify | 기능-알리기.md | 이슈 연결, 영향받는 담당자 찾기, 터미널 알림, 응답, 예외 처리 | 30 | -| app | 앱-화면.md | 데스크톱 앱 화면, 앱 알림, 설치 | 11 | +| app | 앱-화면.md | 데스크톱 앱 화면, 앱 알림, 설치 | 12 | | data | 데이터-구조.md | ID 형식, 기록 형식, 상태 값 | 9 | | copy | 문구.md | 알림과 AI 설명의 문장 규칙, 함께 쓰는 말 | 8 | | work | 일하는-방식.md | 결과물에 영향을 주는 팀 규칙 (위키 기록, 브랜치, 리뷰, 배포) | 10 | diff --git "a/team-wiki/\354\225\261-\355\231\224\353\251\264.md" "b/team-wiki/\354\225\261-\355\231\224\353\251\264.md" index fcfa6ad..41d185a 100644 --- "a/team-wiki/\354\225\261-\355\231\224\353\251\264.md" +++ "b/team-wiki/\354\225\261-\355\231\224\353\251\264.md" @@ -146,3 +146,13 @@ - 항상 적용: 아니오 - 상태: 사용 - 버전: 1 + +### app.detail.change-letter +- 제목: 상세 위 칩은 변경 A, 변경 B처럼 부른다 +- 값: 변경 상세 위 칩은 결정 사항과 내 이슈 한 쌍마다 하나씩 두고 받은 순서대로 변경 A, 변경 B처럼 부른다 +- 이유: 칩 하나가 결정 사항 하나를 가리키는데 결정 사항 ID는 칩에 넣기에 너무 길고 결정 사항에 정해진 번호가 없다 +- 적용 범위: 화면: 나에게 온 변경 / 코드: apps/web/src/screens/** +- 근거: AI 대화 (10/15 호윤님 결정) +- 항상 적용: 아니오 +- 상태: 사용 +- 버전: 1