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
11 changes: 11 additions & 0 deletions Projects/App/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,17 @@ let project = Project(
arguments: .arguments(
environmentVariables: [
"FIRAAppCheckDebugToken": "$(APP_CHECK_DEBUG_TOKEN)"
],
// 분석 QA용 실행 인자 — **꺼진 채로** 스킴에 미리 넣어 둔다.
// -FIRDebugEnabled: Firebase DebugView 에 실시간 표시
// -FIRAnalyticsDebugEnabled: 이벤트·파라미터를 콘솔(os_log)에 상세 출력
//
// 기본을 꺼 두는 이유 — 켜 두면 모든 개발 실행이 DebugView 로 흘러 QA 세션과 섞인다.
// 미리 넣어 두는 이유 — 매번 손으로 타이핑하면 오타가 나고, tuist generate 때마다
// 날아간다. 스킴 편집기에서 체크박스만 켜면 되게 해 둔다.
launchArguments: [
.launchArgument(name: "-FIRDebugEnabled", isEnabled: false),
.launchArgument(name: "-FIRAnalyticsDebugEnabled", isEnabled: false)
]
),
// 스킴 env var 의 `$(...)` 를 App 타깃 빌드세팅 기준으로 확장한다.
Expand Down
43 changes: 40 additions & 3 deletions Projects/Core/Sources/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ public enum AnalyticsEvent {
"candidate_index": candidateIndex,
"candidate_index_label": Self.candidateIndexLabel(candidateIndex),
"edit_count": editCount, "edit_count_bucket": Self.editCountBucket(editCount),
"edited_attrs": editedAttrs, "dwell_ms": dwellMs]
"edited_attrs": editedAttrs,
"dwell_ms": dwellMs, "dwell_bucket": Self.dwellBucket(dwellMs)]
case let .pillFlowExit(pillIndex, editingAttribute, enteredValues, editCount):
return ["pill_index": pillIndex, "pill_index_label": Self.pillIndexLabel(pillIndex),
"editing_attribute": editingAttribute, "entered_values": enteredValues,
Expand Down Expand Up @@ -187,10 +188,46 @@ public enum AnalyticsEvent {
}
}

/// 알약 1개를 확정하기까지 걸린 시간의 분포용 구간(`pill_confirm.dwell_bucket`).
///
/// `dwell_ms` 는 Int 라 **측정항목**으로만 등록된다 — 평균은 보이지만 분류 축으로 쓰면
/// 전부 `(not set)` 이 된다. 분포를 보거나 다른 지표를 시간대로 쪼개려면 문자열 구간이 필요하다.
/// 같은 이벤트의 `edit_count_bucket`·`edited_attrs` 와 교차하면 "많이 고친 알약이 오래
/// 걸리는가"를 바로 볼 수 있다.
///
/// 30초 안쪽은 10초로 한 번 더 갈라 빠른 확정을 구분하고, 30초부터 5분까지는 30초 단위,
/// 그 위는 두 칸으로 닫아 카디널리티를 묶는다 — 개선 대상은 오래 걸린 쪽이다.
///
/// 라벨이 `mm:ss` 인 이유 — 초와 분을 섞으면(`30초-1분`·`1-2분`) 사전순 정렬이 구간 순서와
/// 어긋난다. 자리수가 고정된 `mm:ss` 는 사전순이 곧 시간순이다.
///
/// **값의 성질** — `dwell_ms` 는 수정 화면이 실제로 떠 있던 시간의 **누적**이다(`PillDwellTracker`).
/// 백그라운드·탭 전환 시간은 빠지고, 같은 알약을 여러 번 열면 모든 방문이 합산된다.
/// 이 구간은 그 값을 나눈 것이라 같은 성질을 물려받는다.
/// (~2026-09 에는 마지막 진입 시각부터의 벽시계였다 — 옛 데이터와 같은 축에서 비교하지 말 것.)
private static func dwellBucket(_ ms: Int) -> String {
switch ms {
case ..<0: return "unknown"
case ..<10_000: return "00:00-00:10"
case ..<30_000: return "00:10-00:30"
case ..<60_000: return "00:30-01:00"
case ..<90_000: return "01:00-01:30"
case ..<120_000: return "01:30-02:00"
case ..<150_000: return "02:00-02:30"
case ..<180_000: return "02:30-03:00"
case ..<210_000: return "03:00-03:30"
case ..<240_000: return "03:30-04:00"
case ..<270_000: return "04:00-04:30"
case ..<300_000: return "04:30-05:00"
case ..<600_000: return "05:00-10:00"
default: return "10:00+"
}
}

/// 분석 대기시간 구간(`pill_identify_result.analysis_bucket`).
///
/// `analysis_ms` 와 짝으로 보낸다 — Int 는 GA4 **측정항목**으로만 등록되어 평균은 보이지만
/// 분류 축으로 쓰면 전부 `(not set)` 이 된다. 분포를 보려면 문자열 구간이 필요하다.
/// `dwell_bucket` 과 같은 이유로 문자열 구간을 함께 보낸다 — Int 는 측정항목으로만 등록되어
/// 분류 축으로 쓰면 `(not set)` 이 된다.
///
/// 짧은 쪽을 촘촘히 나눈 건 **대기 이탈이 앞쪽에서 갈리기 때문**이다. 3초와 5초의 차이는
/// 체감이 크지만 40초와 50초의 차이는 이미 "너무 느림" 한 덩어리다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public final class DrugIdentificationCoordinator: BaseCoordinator {

private var pillTabObserver: NSObjectProtocol?

/// 알약별 체류시간 누적기 — `PillEditVC` 가 매번 새로 만들어져도 값이 이어지도록 여기서 소유한다.
/// 이 Coordinator 는 탭당 하나로 앱 수명 내내 살아 있어서, 새 인식 세션마다 `reset()` 해야
/// 이전 사진의 알약 순번과 누적값이 섞이지 않는다.
private let dwellTracker = PillDwellTracker()

public override func start() {
setupOverlay()
setupCameraPickerCallbacks()
Expand Down Expand Up @@ -172,6 +177,8 @@ public final class DrugIdentificationCoordinator: BaseCoordinator {
private weak var resultVC: DrugIdentificationVC?

private func showResult(pills: [IdentifiedPill], image: UIImage, replacing loadingVC: UIViewController) {
// 새 인식 세션 — 이전 사진의 알약별 누적 체류시간을 비운다.
dwellTracker.reset()
let vc = DrugIdentificationVC(pills: pills, image: image)
resultVC = vc
vc.onExitToHome = { [weak self, weak vc] in
Expand Down Expand Up @@ -252,6 +259,7 @@ public final class DrugIdentificationCoordinator: BaseCoordinator {
thumbnail: pill.thumbnail
)
let vc = PillEditVC(viewModel: viewModel)
vc.dwellTracker = dwellTracker
vc.onBackTapped = { [weak self] in
self?.navigationController.popViewController(animated: true)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
//
// PillDwellTracker.swift
// DrugIdentificationFeature
//

import UIKit

/// 알약 1개를 확정하기까지 **수정 화면이 실제로 화면에 떠 있던 시간**을 누적한다
/// (`pill_confirm.dwell_ms` / `dwell_bucket`).
///
/// ## 왜 벽시계를 안 쓰나
///
/// 예전엔 `viewDidLoad` 시각과 확정 시각의 차이를 그대로 보냈다. 그러면 두 방향으로 틀어진다.
///
/// - **과대** — 탭을 옮기거나 앱을 백그라운드로 내려도 시계가 계속 갔다. 실측에서 30초 백그라운드가
/// 그대로 얹혀 72.8초짜리 작업이 102.8초로 기록됐고, 구간이 `01:00-01:30` 에서 `01:30-02:00` 로
/// 한 칸 밀렸다. 분포를 보려는 지표에서 이건 치명적이다.
/// - **과소** — 화면을 나갔다 다시 들어오면 `PillEditVC` 가 새로 만들어져 시계가 0부터 다시 갔다.
/// 같은 알약을 두 번에 걸쳐 고민하면 마지막 방문 시간만 남았다.
///
/// 그래서 **화면 노출 구간만 더하고, 그 합을 알약별로 세션 내내 들고 있는다.**
/// VC 가 다시 만들어져도 이 객체는 Coordinator 가 들고 있으므로 누적값이 이어진다.
///
/// ## 왜 훅이 두 쌍인가
///
/// iOS 는 **앱을 백그라운드로 내려도 `viewDidDisappear` 를 부르지 않는다.** 화면 전환만으로는
/// 백그라운드를 못 잡고, 앱 생명주기만으로는 탭 전환을 못 잡는다. 둘 다 필요하다.
///
/// - `viewDidAppear` / `viewDidDisappear` → 탭 전환, 다른 화면 push
/// - `didEnterBackground` / `willEnterForeground` → 앱 백그라운드
///
/// 백그라운드는 `activeIndex` 를 남긴 채 구간만 끊고(`suspend`), 복귀 시 그 알약으로 다시 잇는다.
/// 화면 전환은 `activeIndex` 까지 지운다(`pause`) — 복귀해도 자동으로 이어지면 안 되고,
/// 그 화면이 실제로 다시 뜰 때 `viewDidAppear` 가 이어 붙이는 게 맞다.
///
/// ## 포함하지 않는 것
///
/// 후보 세부정보·이미지 비교 뷰어를 보는 시간은 **빠진다** — 그 화면이 뜨는 동안 수정 화면은
/// 가려지기 때문이다. "그 알약을 판단하는 데 쓴 시간"으로 보면 포함이 맞다는 해석도 가능해서,
/// 지표를 그렇게 정의하기로 하면 push 구간만 예외로 이어 주면 된다.
final class PillDwellTracker {

/// 알약 순번 → 지금까지 누적된 화면 노출 시간.
private var accumulated: [Int: TimeInterval] = [:]
/// 현재 화면에 떠 있는 알약. 화면이 가려지면 nil, 백그라운드에서는 유지된다.
private var activeIndex: Int?
/// 현재 구간의 시작 시각. 구간이 끊긴 상태면 nil.
private var segmentStart: Date?

private var observers: [NSObjectProtocol] = []

init(notificationCenter: NotificationCenter = .default) {
observers = [
notificationCenter.addObserver(
forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main
) { [weak self] _ in self?.suspend() },
notificationCenter.addObserver(
forName: UIApplication.willEnterForegroundNotification, object: nil, queue: .main
) { [weak self] _ in self?.resumeAfterForeground() }
]
}

deinit {
observers.forEach(NotificationCenter.default.removeObserver)
}

// MARK: - 세션

/// 새 인식 세션 시작 시 호출. Coordinator 는 탭당 하나라 앱 수명 내내 살아 있어서,
/// 비우지 않으면 이전 사진의 알약 순번과 누적값이 섞인다.
func reset() {
accumulated.removeAll()
activeIndex = nil
segmentStart = nil
}

// MARK: - 화면 전환

/// 수정 화면이 떴다 (`viewDidAppear`).
func resume(pillIndex: Int) {
activeIndex = pillIndex
segmentStart = Date()
}

/// 수정 화면이 가려졌다 (`viewDidDisappear`).
func pause() {
flush()
activeIndex = nil
}

// MARK: - 앱 생명주기

/// 앱이 백그라운드로 갔다. `viewDidDisappear` 는 불리지 않으므로 여기서 구간을 끊는다.
/// 어느 알약이었는지는 남겨 둬야 복귀 시 이어 붙일 수 있다.
private func suspend() {
flush()
}

/// 앱이 돌아왔다. 화면이 여전히 그 알약을 보고 있을 때만 구간을 다시 연다 —
/// 다른 탭에 있다가 복귀한 경우엔 `activeIndex` 가 이미 nil 이라 아무 일도 하지 않는다.
private func resumeAfterForeground() {
guard activeIndex != nil, segmentStart == nil else { return }
segmentStart = Date()
}

// MARK: - 조회

/// 지금까지 누적된 화면 노출 시간(ms). 진행 중인 구간이 있으면 함께 더한다.
func elapsedMs(pillIndex: Int) -> Int {
var total = accumulated[pillIndex] ?? 0
if activeIndex == pillIndex, let segmentStart {
total += Date().timeIntervalSince(segmentStart)
}
return Int(total * 1000)
}

// MARK: - 내부

/// 진행 중인 구간을 누적에 더하고 닫는다.
private func flush() {
guard let activeIndex, let segmentStart else { return }
accumulated[activeIndex, default: 0] += Date().timeIntervalSince(segmentStart)
self.segmentStart = nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ final class PillEditVC: UIViewController {

private enum Panel { case none, color, shape, formulation, imprint }
private var openPanel: Panel = .none
/// 체류시간(pill_confirm.dwell_ms) 기준 시각.
private var appearedAt: Date?
/// 체류시간(`pill_confirm.dwell_ms`) 누적기 — Coordinator 가 소유해 화면이 다시 만들어져도 이어진다.
/// 주입되지 않으면(데모 등) 시간은 0 으로 나간다.
var dwellTracker: PillDwellTracker?

// MARK: - UI

Expand Down Expand Up @@ -125,7 +126,6 @@ final class PillEditVC: UIViewController {

override func viewDidLoad() {
super.viewDidLoad()
appearedAt = Date()
navigationController?.setNavigationBarHidden(true, animated: false)
setUI()
setLayout()
Expand Down Expand Up @@ -405,8 +405,17 @@ final class PillEditVC: UIViewController {
/// `isMovingFromParent`/`isBeingDismissed` 로 거르는 이유: 후보 세부정보·이미지 비교 뷰어를
/// push 할 때도 viewDidDisappear 는 불리는데, 그건 이탈이 아니라 잠시 가려지는 것뿐이다.
/// (앱 강제 종료·백그라운드 이탈은 여기로 안 들어온다 — 그건 원래도 못 잡던 경로다)
/// 화면이 실제로 떠 있는 동안만 체류시간을 센다. 세부정보·비교 뷰어를 push 했다 돌아올 때도
/// 여기로 다시 들어와 구간이 이어진다.
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
dwellTracker?.resume(pillIndex: viewModel.pillIndex)
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
// 이탈이든 잠시 가려진 것이든 화면에서 사라지면 시계는 멈춘다.
dwellTracker?.pause()
guard isMovingFromParent || isBeingDismissed else { return }
guard !didConfirm else { return }
trackFlowExit()
Expand All @@ -433,8 +442,7 @@ final class PillEditVC: UIViewController {
}

private func dwellMs() -> Int {
guard let appearedAt else { return 0 }
return Int(Date().timeIntervalSince(appearedAt) * 1000)
dwellTracker?.elapsedMs(pillIndex: viewModel.pillIndex) ?? 0
}

// MARK: - Bind
Expand Down