From 9f554eb7bd71a2e23e1a6c9a3a4c38e3311b9bba Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 8 Jul 2026 20:41:30 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix(badge):=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=B0=B0=EC=A7=80=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=A7=80=EC=97=B0=20=EB=A1=9C=EB=94=A9=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배지 획득 상태(/api/v1/badges)와 진행 카운트용 전체 회고 조회를 Promise.all로 묶어, 느린 회고 조회가 끝나야 배지가 렌더됐다. 획득 상태는 배지 API만으로 먼저 채우고, 진행 카운트는 뒤에서 비동기로 갱신하도록 분리해 배지 이미지가 즉시 뜨게 함. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/composables/useBadges.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/composables/useBadges.ts b/app/composables/useBadges.ts index 63c001e..f791cb6 100644 --- a/app/composables/useBadges.ts +++ b/app/composables/useBadges.ts @@ -96,10 +96,9 @@ export function useBadges() { // throwOnError=true면 에러를 던진다(useLoadState.run과 함께 에러 화면 분기용) async function load(throwOnError = false) { try { - const [badgeRes, retroCount] = await Promise.all([ - $api.get>('/api/v1/badges'), - fetchRetroCount(), - ]) + // 배지 획득 상태·이미지는 /api/v1/badges 만으로 결정 → 먼저 채워 배지 이미지가 바로 뜨게 한다. + // (진행 카운트용 전체 회고 조회는 느리므로 묶지 않고 뒤에서 비동기로 갱신) + const badgeRes = await $api.get>('/api/v1/badges') // 백엔드는 conditionType + threshold 로 식별 const byKey = new Map() for (const item of badgeRes.data.data ?? []) { @@ -112,10 +111,17 @@ export function useBadges() { image: `/badges/${def.code}.svg`, acquired: api?.acquired ?? false, acquiredAt: api?.acquiredAt ?? null, - // 회고 수 기준 배지만 진행 카운트 표기 (목표 초과 시 목표값으로 캡) - current: def.countable ? Math.min(retroCount, def.goal ?? retroCount) : 0, + current: 0, } }) + // 진행 카운트(countable 배지)는 별도로 조회 후 갱신 — 배지 이미지 노출을 막지 않음 + void fetchRetroCount().then((retroCount) => { + badges.value = badges.value.map((b) => ({ + ...b, + // 회고 수 기준 배지만 진행 카운트 표기 (목표 초과 시 목표값으로 캡) + current: b.countable ? Math.min(retroCount, b.goal ?? retroCount) : 0, + })) + }) } catch (e) { badges.value = buildDefault() if (throwOnError) throw e From 26d7bcc91562bb2011f347307edc52d87e3a832c Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 8 Jul 2026 20:41:30 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore(dev):=20Capacitor=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EB=A6=AC=EB=A1=9C=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev:host(0.0.0.0 바인딩) + cap:live:android/ios로 실기기에서 코드 변경을 즉시 HMR 반영할 수 있게 함. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index d807876..5f276cb 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev", + "dev:host": "nuxt dev --host 0.0.0.0", + "cap:live:android": "cap run android --live-reload --external --port 3000", + "cap:live:ios": "cap run ios --live-reload --external --port 3000", "generate": "nuxt generate", "cap:sync": "nuxt generate && cap sync", "cap:sync:prod": "nuxt generate --dotenv .env.production && cap sync", From f778c24cbdafb67b5814e5c5b2bebb3eefd1633e Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 8 Jul 2026 21:06:20 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(auth):=20=EA=B3=84=EC=A0=95=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8B=9C=20=EC=9D=B4=EC=A0=84=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=85=B8=EC=B6=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로그아웃은 navigateTo로 SPA 이동이라 페이지가 리로드되지 않아 전역 useState 캐시(projects:list·retrospects:list·home:* 등)가 남았고, 다른 계정 재로그인 시 이전 계정의 프로젝트·회고가 그대로 노출됐다. logout()에서 clearNuxtState()로 전역 상태를 전부 비워 계정 전환 시 데이터가 남지 않도록 수정. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/stores/auth.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/app/stores/auth.ts b/app/stores/auth.ts index 0334020..c711887 100644 --- a/app/stores/auth.ts +++ b/app/stores/auth.ts @@ -5,19 +5,17 @@ export const useAuthStore = defineStore('auth', () => { const user = ref(null) const isLoggedIn = computed(() => !!user.value) - // useProfile()과 동일한 전역 캐시 키 — 로그아웃 시 함께 비워 - // 다른 계정으로 재로그인했을 때 이전 계정 프로필(닉네임 등)이 남는 것을 방지 - const profileCache = useState('user:profile', () => null) - const profileInflight = useState | null>('user:profile:inflight', () => null) - function setUser(profile: UserProfile) { user.value = profile } function logout() { user.value = null - profileCache.value = null - profileInflight.value = null + // 로그아웃은 navigateTo로 SPA 이동이라 페이지가 리로드되지 않는다. + // → 전역 useState 캐시(프로필·프로젝트·회고·홈·알림 등)를 모두 비워 + // 다른 계정으로 재로그인했을 때 이전 계정 데이터가 남는 것을 방지한다. + // (user 스코프가 아닌 키는 다음 접근 시 기본값 팩토리로 재생성됨) + clearNuxtState() localStorage.removeItem('accessToken') localStorage.removeItem('refreshToken') } From eee1a009e9736d6dc8db0d1eb834208e6e308e2e Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 8 Jul 2026 21:08:32 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(auth):=20=EB=84=A4=EC=9D=B4=ED=8B=B0?= =?UTF-8?q?=EB=B8=8C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=9C=A0=EC=A7=80=C2=B7=ED=91=B8=EC=8B=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20=EC=95=8C=EB=A6=BC=20=EB=94=A5?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=BD=9C=EB=93=9C=EC=8A=A4=ED=83=80?= =?UTF-8?q?=ED=8A=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 시 isOnboardingCompleted를 localStorage에 저장 → 앱 재실행 때 스플래시(index.vue)가 세션을 유지하도록 함 (플래그가 없어 매번 세션이 지워지고 재로그인되던 문제 해결) - 로그인 직후 syncIfConsented() 호출로 동의 사용자의 기기 토큰을 즉시 등록 → 앱 재시작 전까지 토큰이 등록되지 않아 카카오 등 로그인 계정에서 푸시가 누락되던 문제 해결 - 알림 탭 딥링크를 콜드 스타트 시 pendingDeepLink에 담아 스플래시가 인증 확인 후 이동하도록 함 (종료 상태에서 알림을 눌러 실행 시 로그인 화면으로 튕기던 문제 해결) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/composables/usePushNotifications.ts | 24 +++++++++++++++++++++--- app/pages/index.vue | 10 ++++++++-- app/pages/login.vue | 7 +++++++ 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/composables/usePushNotifications.ts b/app/composables/usePushNotifications.ts index c5c5584..af8cd7b 100644 --- a/app/composables/usePushNotifications.ts +++ b/app/composables/usePushNotifications.ts @@ -9,6 +9,10 @@ import { getMessaging, getToken, onMessage, isSupported } from 'firebase/messagi // 리스너 중복 바인딩 방지 — 컴포저블이 여러 곳(app.vue·알림 설정)에서 호출돼도 앱 전체에서 1회만 let nativeListenersBound = false +// 알림 탭으로 앱이 콜드 스타트되면 딥링크가 스플래시(index.vue)의 홈 이동에 덮여 사라진다. +// 이 경우 링크를 여기 담아두고, index.vue가 인증 확인 후 consumePendingDeepLink()로 소비한다. +let pendingDeepLink: string | null = null + export function usePushNotifications() { const config = useRuntimeConfig().public.firebase const { $api } = useNuxtApp() @@ -47,10 +51,17 @@ export function usePushNotifications() { const body = notification.body ?? notification.data?.body ?? '' show(body || title) }) - // 알림 탭 시 백엔드가 data.link로 내려준 화면으로 이동 ('/'는 index가 로그인 상태 보고 홈으로 분기) + // 알림 탭 시 백엔드가 data.link로 내려준 화면으로 이동. + // 콜드 스타트(아직 스플래시 '/')면 pendingDeepLink에 담아 index.vue가 인증 확인 후 이동하고, + // 앱 실행 중(웜)이면 현재 라우터로 바로 이동한다. PushNotifications.addListener('pushNotificationActionPerformed', (action) => { const link = action.notification.data?.link - if (typeof link === 'string' && link.startsWith('/')) router.push(link) + if (typeof link !== 'string' || !link.startsWith('/')) return + if (router.currentRoute.value.path === '/') { + pendingDeepLink = link + } else { + router.push(link) + } }) } @@ -177,5 +188,12 @@ export function usePushNotifications() { } } - return { register, listenForeground, syncIfConsented, deviceType, bindNativeListeners } + // 스플래시(index.vue)가 인증 확인 후 호출 — 알림 탭으로 콜드 스타트된 경우 대기 중인 딥링크를 꺼내 소비한다 + function consumePendingDeepLink(): string | null { + const link = pendingDeepLink + pendingDeepLink = null + return link + } + + return { register, listenForeground, syncIfConsented, deviceType, bindNativeListeners, consumePendingDeepLink } } diff --git a/app/pages/index.vue b/app/pages/index.vue index 69f6a24..a50d9cd 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -13,6 +13,12 @@ definePageMeta({ layout: false }) const { $api } = useNuxtApp() const runtimeConfig = useRuntimeConfig() +const push = usePushNotifications() + +// 인증·온보딩 완료 사용자의 최종 목적지 — 알림 탭 딥링크가 있으면 그쪽 우선, 없으면 홈 +function authedDestination(): string { + return push.consumePendingDeepLink() ?? '/home' +} onMounted(async () => { // 이미 로그인된 경우 스플래시 없이 즉시 이동 @@ -23,7 +29,7 @@ onMounted(async () => { navigateTo('/login', { replace: true }) return } - navigateTo(isOnboardingCompleted === 'true' ? '/home' : '/onboarding', { replace: true }) + navigateTo(isOnboardingCompleted === 'true' ? authedDestination() : '/onboarding', { replace: true }) return } @@ -75,7 +81,7 @@ onMounted(async () => { navigateTo('/login', { replace: true }) return } - navigateTo(isOnboardingCompleted === 'true' ? '/home' : '/onboarding', { replace: true }) + navigateTo(isOnboardingCompleted === 'true' ? authedDestination() : '/onboarding', { replace: true }) } catch { navigateTo('/login', { replace: true }) } diff --git a/app/pages/login.vue b/app/pages/login.vue index 499a7f4..1130581 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -112,6 +112,8 @@ const isLoading = ref(false) // 로그인 실패·안내 토스트는 notice 아이콘 표시 (피그마 8088:31255) const { show } = useToast() const showToast = (msg: string) => show(msg, { icon: true }) +// 로그인 성공 직후 푸시 기기 토큰을 등록하기 위해 사용 +const push = usePushNotifications() onMounted(async () => { // accessToken과 refreshToken이 모두 있을 때만 자동 로그인 @@ -177,6 +179,11 @@ async function submitLogin(provider: 'KAKAO' | 'GOOGLE' | 'APPLE', oauthToken: s const { data } = await $api.post>('/api/v1/auth/login', { provider, oauthToken }) localStorage.setItem('accessToken', data.data.accessToken) localStorage.setItem('refreshToken', data.data.refreshToken) + // 온보딩 완료 여부를 저장해야 다음 앱 실행 시 스플래시(index.vue)가 세션을 유지한다. + // (이 값이 없으면 스플래시가 미로그인으로 간주해 localStorage를 비우고 로그인 화면으로 보냄 → 매번 재로그인) + localStorage.setItem('isOnboardingCompleted', String(data.data.isOnboardingCompleted)) + // 로그인 직후 푸시 동의 상태면 기기 토큰을 즉시 등록한다 (앱 재실행 전까지 알림이 누락되던 문제 방지) + push.syncIfConsented() identify(data.data.accessToken, { provider: provider.toLowerCase() }) From 14f18ab66dbff8be3128d8489dae75f7a7a86b6a Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 8 Jul 2026 21:26:14 +0900 Subject: [PATCH 5/6] =?UTF-8?q?feat(home):=20=ED=9A=8C=EA=B3=A0=20?= =?UTF-8?q?=EC=9C=A0=EB=AC=B4=C2=B7=EB=A0=88=EB=B2=A8=EB=B3=84=20=ED=99=88?= =?UTF-8?q?/=EB=A7=88=EC=9D=B4=20=EC=83=81=ED=83=9C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EC=B5=9C=EA=B3=A0=EB=A0=88=EB=B2=A8=20=EC=B9=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - home: 빈 화면 제거, 회고 유무와 무관하게 인사말+미션 카드·FAB 항상 노출 - home: 최고레벨(레벨10, 미션 없음)에 이번 주 회고 현황 카드(HomeMissionMaxCard) 추가 - HomeMissionCard·types: weeklyStatus 계약을 백엔드 days(boolean[])에 맞춤 - my: 레벨 배지를 레벨 1 이상(회고 O)에서만 노출 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/HomeMissionCard.vue | 9 ++- app/components/HomeMissionMaxCard.vue | 29 ++++++++++ app/pages/home.vue | 79 +++++++++------------------ app/pages/my/index.vue | 9 ++- app/types/api.ts | 11 ++-- 5 files changed, 70 insertions(+), 67 deletions(-) create mode 100644 app/components/HomeMissionMaxCard.vue diff --git a/app/components/HomeMissionCard.vue b/app/components/HomeMissionCard.vue index f533aa7..e31066e 100644 --- a/app/components/HomeMissionCard.vue +++ b/app/components/HomeMissionCard.vue @@ -152,9 +152,12 @@ function rowFillFrac(row: number[]): number { // 레벨별 색상은 공용 유틸(levelTheme)에서 — 미션카드·마이페이지 공유 const theme = computed(() => levelTheme(missionLevel.value)) -// 주간 미션 요일 스탬프 — weeklyStatus가 없어도 기본 요일로 항상 노출 -const DEFAULT_WEEK_DAYS = ['월', '화', '수', '목', '금', '토', '일'].map((day) => ({ day, isCompleted: false })) -const weekDays = computed(() => props.data.weeklyStatus?.weekDays ?? DEFAULT_WEEK_DAYS) +// 주간 미션 요일 스탬프 — 백엔드 days(월~일 boolean[])를 요일 라벨과 zip. 데이터 없으면 전부 미완료 +const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'] +const weekDays = computed(() => { + const days = props.data.weeklyStatus?.days ?? [] + return WEEKDAY_LABELS.map((day, i) => ({ day, isCompleted: days[i] ?? false })) +}) // 진행 바 채움: 해당 주차의 흰 점을 완전히 덮는 지점까지 (figma 실측: 바 306px 기준 1/2 진행 = 163px) const barWidth = computed(() => { diff --git a/app/components/HomeMissionMaxCard.vue b/app/components/HomeMissionMaxCard.vue new file mode 100644 index 0000000..521a2ac --- /dev/null +++ b/app/components/HomeMissionMaxCard.vue @@ -0,0 +1,29 @@ + + + diff --git a/app/pages/home.vue b/app/pages/home.vue index 8672a0a..5eb48f1 100644 --- a/app/pages/home.vue +++ b/app/pages/home.vue @@ -29,69 +29,28 @@ - -
- -
- - + - -
- - - -
- - +
@@ -111,8 +70,15 @@ {{ greetingMessage }} + +
- +
@@ -229,6 +195,11 @@ const FIRST_MISSION: CurrentMissionResponse = { weeklyStatus: null, popup: { exists: false, type: null }, } +// 최고 레벨: 백엔드가 mission=null + currentLevel=10으로 내려줌 (진행할 미션 없음) +const MAX_LEVEL = 10 +const isMaxLevel = computed( + () => mission.value != null && mission.value.mission == null && mission.value.currentLevel >= MAX_LEVEL, +) // 미션이 비어 있으면 기본(첫 회고) 카드로 대체 — 카드가 항상 레벨에 맞게 뜨도록 const displayMission = computed(() => mission.value?.mission ? mission.value : FIRST_MISSION, diff --git a/app/pages/my/index.vue b/app/pages/my/index.vue index 553d698..e5207d5 100644 --- a/app/pages/my/index.vue +++ b/app/pages/my/index.vue @@ -13,8 +13,9 @@

{{ jobLabel }}

{{ profile?.nickname ?? '' }}

- + Lv.{{ displayLevel }} @@ -142,8 +143,10 @@ const jobLabels: Record = { const jobLabel = computed(() => (profile.value?.job ? jobLabels[profile.value.job] : '')) -// 백엔드 currentLevel은 '달성한 레벨'이라 신규 유저는 0 — 표시 레벨은 최소 1 (모든 유저 Lv.1부터) -const displayLevel = computed(() => Math.max(profile.value?.currentLevel ?? 1, 1)) +// 백엔드 currentLevel은 '달성한 레벨'이라 신규·회고X 유저는 0 +// → 레벨 1 이상(회고 O)부터 배지 노출, 그 전엔 배지 자체를 숨김 +const showLevelBadge = computed(() => (profile.value?.currentLevel ?? 0) >= 1) +const displayLevel = computed(() => profile.value?.currentLevel ?? 1) const { badges, load: loadBadges } = useBadges() const acquiredBadges = computed(() => diff --git a/app/types/api.ts b/app/types/api.ts index 3c75407..b24c50c 100644 --- a/app/types/api.ts +++ b/app/types/api.ts @@ -131,14 +131,11 @@ export interface RecentRetrospective { // Mission / Level (게이미피케이션) — GET /api/v1/missions/current export type MissionType = 'FIRST_RETRO' | 'TIME_LIMITED' | 'CONSECUTIVE_WEEK' | 'CUMULATIVE_RETRO' -// 연속 주 미션에서만 내려옴 (월~일 7요소) -export interface WeekDayStatus { - day: string - isCompleted: boolean -} - +// 연속 주 미션에서만 내려옴. 백엔드 WeeklyStatus 계약: days는 이번 주 월~일 7개 boolean export interface WeeklyRetroStatus { - weekDays: WeekDayStatus[] + show: boolean + weekStart: string + days: boolean[] } // 미션 상세 (최고 레벨 등에서는 mission = null) From a150e87379223e6895b6e2ee647f856abe77acc4 Mon Sep 17 00:00:00 2001 From: Develop-KIM Date: Wed, 8 Jul 2026 21:26:14 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(retrospect):=20=ED=95=9C=EA=B8=80=20IME?= =?UTF-8?q?=20=EC=A1=B0=ED=95=A9=20=EC=A4=91=20=EC=9D=8C=EC=84=B1=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=86=A0=EA=B8=80=20=EC=98=A4=EC=9E=91?= =?UTF-8?q?=EB=8F=99=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOM 입력값을 직접 추적(hasInput)해 조합 중에도 입력 유무를 즉시 반영 Co-Authored-By: Claude Opus 4.8 (1M context) --- app/pages/retrospect/start.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/pages/retrospect/start.vue b/app/pages/retrospect/start.vue index 0775609..2dea536 100644 --- a/app/pages/retrospect/start.vue +++ b/app/pages/retrospect/start.vue @@ -311,7 +311,9 @@ const hasAnswered = computed(() => messages.value.some((m) => m.role === 'user') const voiceSupported = computed( () => isNative.value || (import.meta.client && !!navigator.mediaDevices?.getUserMedia), ) -const showVoice = computed(() => voiceSupported.value && inputText.value.trim().length === 0) +// 한글 IME 조합 중에는 v-model(inputText)이 갱신되지 않아, DOM 값을 직접 추적해 즉시 반영 +const hasInput = ref(false) +const showVoice = computed(() => voiceSupported.value && !hasInput.value) const exitDescription = computed(() => hasAnswered.value ? '지금까지 작성한 내용은 저장되지 않으며, 오늘 회고 횟수 1회가 차감돼요.' @@ -357,6 +359,8 @@ function autoGrow() { el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 120)}px` isMultiline.value = el.scrollHeight > 40 + // 조합 중 글자를 포함해 실제 입력 유무 판단 (IME 대응) + hasInput.value = el.value.trim().length > 0 } // 질문 순번 계산: questionType('Q1'..)에서 숫자 추출, 없으면 카운터 증가 @@ -642,6 +646,7 @@ async function onConfirmRestart() { retrospectiveId.value = res.retrospectiveId messages.value = [] inputText.value = '' + hasInput.value = false questionNo.value = 0 deepAsked.value = false lastDeepId.value = null