Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension AppGraph {
prepareDependencies { dependencies in
prepareEntryDependencies(&dependencies)
prepareDevelopmentDependencies(&dependencies)
prepareDevelopmentTabDependencies(&dependencies)
prepareTodoDependencies(&dependencies)
prepareHomeDependencies(&dependencies)
prepareTodayDependencies(&dependencies)
Expand All @@ -23,7 +24,7 @@ extension AppGraph {

private extension AppGraph {
func prepareDevelopmentDependencies(_ dependencies: inout DependencyValues) {
DevelopmentDependencyPreparation.prepareGoal(
DevelopmentTabPresentationDependencyPreparation.prepareGoal(
&dependencies,
createGoalUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
Expand All @@ -35,7 +36,7 @@ private extension AppGraph {
.developmentGoalUseCaseGraph
.updateDevelopmentGoalStatusUseCase
)
DevelopmentDependencyPreparation.prepareQuery(
DevelopmentTabPresentationDependencyPreparation.prepareQuery(
&dependencies,
fetchRecordsUseCase: developmentGraphSet
.developmentRecordQueryUseCaseGraph
Expand All @@ -47,7 +48,7 @@ private extension AppGraph {
.developmentRecordQueryUseCaseGraph
.fetchDevelopmentRecordVersionUseCase
)
DevelopmentDependencyPreparation.prepareMutation(
DevelopmentTabPresentationDependencyPreparation.prepareMutation(
&dependencies,
createRecordUseCase: developmentGraphSet
.developmentRecordMutationUseCaseGraph
Expand All @@ -62,13 +63,25 @@ private extension AppGraph {
.developmentRecordMutationUseCaseGraph
.restoreDevelopmentRecordUseCase
)
DevelopmentDependencyPreparation.prepareTodo(
DevelopmentTabPresentationDependencyPreparation.prepareTodo(
&dependencies,
fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase,
updateTodoGoalUseCase: todoGraphSet.todoGoalUseCaseGraph.updateTodoGoalUseCase
)
}

func prepareDevelopmentTabDependencies(_ dependencies: inout DependencyValues) {
DevelopmentTabPresentationDependencyPreparation.prepareGoalList(
&dependencies,
goalsUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.fetchDevelopmentGoalsUseCase,
recentRecordUseCase: developmentGraphSet
.developmentRecordQueryUseCaseGraph
.fetchRecentDevelopmentRecordUseCase
)
}

func prepareEntryDependencies(_ dependencies: inout DependencyValues) {
PresentationDependencyPreparation.prepareRoot(
&dependencies,
Expand Down Expand Up @@ -133,12 +146,9 @@ private extension AppGraph {
fetchGoalsUseCase: developmentGraphSet
.developmentGoalUseCaseGraph
.fetchDevelopmentGoalsUseCase,
fetchRecordsUseCase: developmentGraphSet
.developmentRecordQueryUseCaseGraph
.fetchDevelopmentRecordsUseCase,
fetchRecordVersionUseCase: developmentGraphSet
recentRecordUseCase: developmentGraphSet
.developmentRecordQueryUseCaseGraph
.fetchDevelopmentRecordVersionUseCase,
.fetchRecentDevelopmentRecordUseCase,
fetchTodosUseCase: todoGraphSet.todoUseCaseGraph.fetchTodosUseCase
)
HomePresentationDependencyPreparation.prepareTodoCategory(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ public final class DevelopmentRecordQueryUseCaseGraph {
FetchDevelopmentRecordsUseCaseImpl(input.repository)
}

@Provide
private func makeFetchRecentDevelopmentRecordUseCase() -> FetchRecentDevelopmentRecordUseCase {
FetchRecentDevelopmentRecordUseCaseImpl(input.repository)
}

@Provide
private func makeFetchDevelopmentRecordHistoryUseCase() -> FetchDevelopmentRecordHistoryUseCase {
FetchDevelopmentRecordHistoryUseCaseImpl(input.repository)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// FetchRecentDevelopmentRecordUseCase.swift
// Domain
//
// Created by opfic on 10/5/26.
//

public protocol FetchRecentDevelopmentRecordUseCase {
func execute(goalId: String) async throws -> DevelopmentRecord.Version?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// FetchRecentDevelopmentRecordUseCaseImpl.swift
// Domain
//
// Created by opfic on 10/5/26.
//

public final class FetchRecentDevelopmentRecordUseCaseImpl: FetchRecentDevelopmentRecordUseCase {
private let repository: DevelopmentRecordRepository

init(_ repository: DevelopmentRecordRepository) {
self.repository = repository
}

public func execute(goalId: String) async throws -> DevelopmentRecord.Version? {
let records = try await repository.fetchRecords(goalId: goalId)
var versions = [DevelopmentRecord.Version]()

try await withThrowingTaskGroup(of: DevelopmentRecord.Version.self) { group in
for record in records {
guard let currentVersion = record.currentVersion else { continue }
group.addTask { [repository] in
try await repository.fetchVersion(
goalId: goalId,
recordId: record.id,
versionId: currentVersion.id
)
}
}

for try await version in group {
versions.append(version)
}
}

return versions.max { $0.confirmedAt < $1.confirmedAt }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//
// FetchRecentDevelopmentRecordUseCaseTests.swift
// DomainTests
//
// Created by opfic on 10/5/26.
//

import Foundation
import Testing
@testable import Domain

struct FetchRecentDevelopmentRecordUseCaseTests {
@Test("각 기록의 현재 확정 버전 중 확정 시각이 가장 늦은 버전을 반환한다")
func latestConfirmedVersion() async throws {
let olderRecord = try makeRecentRecordFixture(id: "older-record", number: 2, confirmedAt: 300, createdAt: 0)
let newerRecord = try makeRecentRecordFixture(id: "newer-record", number: 1, confirmedAt: 200, createdAt: 100)
let spy = DevelopmentRecordRepositorySpy(
records: [newerRecord.record, olderRecord.record],
versions: [newerRecord.version, olderRecord.version]
)
let useCase = FetchRecentDevelopmentRecordUseCaseImpl(spy)

let version = try await useCase.execute(goalId: "goal-1")

#expect(version == olderRecord.version)
#expect(await spy.recordQueries() == ["goal-1"])
#expect(await spy.exactVersionQueries().sorted { $0.recordId < $1.recordId } == [
.init(goalId: "goal-1", recordId: "newer-record", versionId: newerRecord.version.id),
.init(goalId: "goal-1", recordId: "older-record", versionId: olderRecord.version.id)
])
}

@Test("현재 확정 버전이 없는 기록은 건너뛴다")
func skipsDrafts() async throws {
let record = try makeDevelopmentRecordInitialDraft(updatedAt: .now)
let confirmedRecord = try makeRecentRecordFixture(id: "confirmed", number: 1, confirmedAt: 100, createdAt: 0)
let spy = DevelopmentRecordRepositorySpy(
records: [record, confirmedRecord.record],
versions: [confirmedRecord.version]
)
let useCase = FetchRecentDevelopmentRecordUseCaseImpl(spy)

let version = try await useCase.execute(goalId: "goal-1")

#expect(version == confirmedRecord.version)
#expect(await spy.exactVersionQueries() == [
.init(goalId: "goal-1", recordId: "confirmed", versionId: confirmedRecord.version.id)
])
}

@Test("버전 조회가 실패하면 성공한 다른 버전이 있어도 오류를 그대로 던진다")
func versionFailure() async throws {
let record = try makeDevelopmentRecordConfirmed()
let confirmedRecord = try makeRecentRecordFixture(id: "confirmed", number: 1, confirmedAt: 100, createdAt: 0)
let spy = DevelopmentRecordRepositorySpy(
records: [record, confirmedRecord.record],
versions: [confirmedRecord.version]
)
let useCase = FetchRecentDevelopmentRecordUseCaseImpl(spy)

await #expect(throws: DevelopmentRecordRepositorySpyError.self) {
try await useCase.execute(goalId: "goal-1")
}
}


}

private func makeRecentRecordFixture(
id: String,
number: Int,
confirmedAt: TimeInterval,
createdAt: TimeInterval
) throws -> (record: DevelopmentRecord, version: DevelopmentRecord.Version) {
let version = try DevelopmentRecord.Version(
id: "\(id)-v\(number)",
recordId: id,
number: number,
title: "기록",
markdownContent: "본문",
kind: number == 1 ? .initial : .correction,
sourceVersionId: number == 1 ? nil : "\(id)-v\(number - 1)",
confirmedAt: Date(timeIntervalSince1970: confirmedAt)
)
let record = try DevelopmentRecord(
id: id,
goalId: "goal-1",
currentVersion: .init(id: version.id, number: number),
draft: nil,
createdAt: Date(timeIntervalSince1970: createdAt)
)
return (record, version)
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
//
// DevelopmentDependencyPreparation.swift
// Development
// DevelopmentTabDependencyPreparation.swift
// DevelopmentTab
//
// Created by opfic on 9/13/26.
// Created by opfic on 10/5/26.
//

import Domain
import PresentationShared

public enum DevelopmentDependencyPreparation {
public enum DevelopmentTabDependencyPreparation {
public static func prepareGoalList(
_ dependencies: inout DependencyValues,
goalsUseCase: FetchDevelopmentGoalsUseCase,
recentRecordUseCase: FetchRecentDevelopmentRecordUseCase
) {
dependencies.developmentTabFetchGoalsUseCase = goalsUseCase
dependencies.developmentTabFetchRecentRecordUseCase = recentRecordUseCase
}

public static func prepareGoal(
_ dependencies: inout DependencyValues,
createGoalUseCase: CreateDevelopmentGoalUseCase,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// DevelopmentGoalCard.swift
// DevelopmentTab
//
// Created by opfic on 10/5/26.
//

import Domain
import PresentationShared
import SwiftUI

struct DevelopmentGoalCard: View {
let goal: DevelopmentGoal
let recentRecord: DevelopmentGoalListFeature.RecentRecord?

var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(alignment: .top, spacing: 12) {
Text(goal.title)
.font(.title3.weight(.semibold))
.foregroundStyle(Color.primary)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
GoalStatusBadge(status: goal.status)
}
if !goal.description.isEmpty {
Text(goal.description)
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.lineLimit(3)
.frame(maxWidth: .infinity, alignment: .leading)
}
if case .loaded(let version?) = recentRecord {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "clock")
.foregroundStyle(Color.textTertiary)
VStack(alignment: .leading, spacing: 6) {
Text(String.localizedStringWithFormat(
String(localized: "development_goals_recent_record_format", bundle: PresentationResources.bundle),
version.title
))
.font(.subheadline)
.foregroundStyle(Color.textSecondary)
.lineLimit(1)
TimelineView(.periodic(from: .now, by: 1.0)) { context in
Text(String.localizedStringWithFormat(
String(localized: "development_goals_recent_record_date_format", bundle: PresentationResources.bundle),
RelativeTime.text(from: version.confirmedAt, now: context.date),
version.confirmedAt.formatted(.dateTime.month().day().hour().minute()),
RecordPresentation.versionLabel(version.number)
))
.font(.caption)
.foregroundStyle(Color.textTertiary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(12)
.background(Color.appBackground, in: .rect(cornerRadius: 12))
}
}
.padding(18)
.background(Color.surface, in: .rect(cornerRadius: 24))
.contentShape(.rect)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// DevelopmentGoalListFeature+Dependencies.swift
// DevelopmentTab
//
// Created by opfic on 10/5/26.
//

import Domain
import PresentationShared

extension DependencyValues {
var developmentTabFetchGoalsUseCase: FetchDevelopmentGoalsUseCase {
get { self[FetchDevelopmentGoalsUseCaseKey.self] }
set { self[FetchDevelopmentGoalsUseCaseKey.self] = newValue }
}

var developmentTabFetchRecentRecordUseCase: FetchRecentDevelopmentRecordUseCase {
get { self[FetchRecentDevelopmentRecordUseCaseKey.self] }
set { self[FetchRecentDevelopmentRecordUseCaseKey.self] = newValue }
}
}

private enum FetchDevelopmentGoalsUseCaseKey: DependencyKey {
static var liveValue: FetchDevelopmentGoalsUseCase {
preconditionFailure("FetchDevelopmentGoalsUseCase must be provided.")
}
}

private enum FetchRecentDevelopmentRecordUseCaseKey: DependencyKey {
static var liveValue: FetchRecentDevelopmentRecordUseCase {
preconditionFailure("FetchRecentDevelopmentRecordUseCase must be provided.")
}
}
Loading
Loading