From 44978b36d95c2af554bd29f75c536fba1dd493a8 Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 18:52:30 +0200 Subject: [PATCH 1/8] Add client unit tests --- .github/workflows/ci_clients.yml | 2 +- composeApp/build.gradle.kts | 180 +++++++++--------- .../commonTest/kotlin/AppConfigurationTest.kt | 37 ++++ .../navigation/SystemNavigation.desktop.kt | 15 +- gradle/libs.versions.toml | 9 + 5 files changed, 153 insertions(+), 90 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/AppConfigurationTest.kt diff --git a/.github/workflows/ci_clients.yml b/.github/workflows/ci_clients.yml index 1b5e1dcc..5d8ab653 100644 --- a/.github/workflows/ci_clients.yml +++ b/.github/workflows/ci_clients.yml @@ -66,5 +66,5 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Run unit tests - run: ./gradlew jvmTest + run: ./gradlew desktopTest diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index e2a535c6..53039246 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,118 +1,124 @@ import com.google.devtools.ksp.gradle.KspTask plugins { - alias(libs.plugins.kotlinMultiplatform) - alias(libs.plugins.androidApplication) - alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) - alias(libs.plugins.ksp) - idea + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.ksp) + idea } kotlin { - js(IR) { - browser() - binaries.executable() - } + js(IR) { + browser() + binaries.executable() + } - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = "11" - } - } + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "11" + } } + } - jvm("desktop") + jvm("desktop") - listOf( - iosX64(), - iosArm64(), - iosSimulatorArm64() - ).forEach { iosTarget -> - iosTarget.binaries.framework { - baseName = "ComposeApp" - isStatic = true - } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "ComposeApp" + isStatic = true } + } - sourceSets { - commonMain { - kotlin.srcDirs("build/generated/ksp/commonMain/kotlin") - } + sourceSets { + commonMain { + kotlin.srcDirs("build/generated/ksp/commonMain/kotlin") + } + commonTest { + kotlin.srcDir("build/generated/ksp/test/kotlin") + } + dependencies { + ksp(libs.arrow.optics.ksp) + } - val desktopMain by getting + val desktopMain by getting - androidMain.dependencies { - implementation(compose.preview) - implementation(libs.androidx.activity.compose) - } - commonMain.dependencies { - implementation(projects.shared) - implementation(compose.runtime) - implementation(compose.foundation) - implementation(compose.material) - implementation(compose.ui) - implementation(compose.components.resources) - implementation(compose.components.uiToolingPreview) - implementation(libs.kotlin.immutableCollections) - implementation(libs.thirdparty.lottieMultiplatform) - implementation(libs.thirdparty.kamel) - implementation(libs.bundles.arrow) - } - desktopMain.dependencies { - implementation(compose.desktop.currentOs) - } + androidMain.dependencies { + implementation(compose.preview) + implementation(libs.androidx.activity.compose) } -} - -dependencies { - ksp(libs.arrow.optics.ksp) + commonMain.dependencies { + implementation(projects.shared) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.kotlin.immutableCollections) + implementation(libs.thirdparty.lottieMultiplatform) + implementation(libs.thirdparty.kamel) + implementation(libs.bundles.arrow) + } + commonTest.dependencies { + implementation(libs.bundles.test.kmp) + } + desktopMain.dependencies { + implementation(compose.desktop.currentOs) + } + } } android { - namespace = "ivy.learn" - compileSdk = libs.versions.android.compileSdk.get().toInt() + namespace = "ivy.learn" + compileSdk = libs.versions.android.compileSdk.get().toInt() - sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") - sourceSets["main"].res.srcDirs("src/androidMain/res") - sourceSets["main"].resources.srcDirs("src/commonMain/resources") + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + sourceSets["main"].res.srcDirs("src/androidMain/res") + sourceSets["main"].resources.srcDirs("src/commonMain/resources") - defaultConfig { - applicationId = "ivy.learn" - minSdk = libs.versions.android.minSdk.get().toInt() - targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 1 - versionName = "1.0" - } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } - buildTypes { - getByName("release") { - isMinifyEnabled = false - } + defaultConfig { + applicationId = "ivy.learn" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + } + buildTypes { + getByName("release") { + isMinifyEnabled = false } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } } compose.experimental { - web.application {} + web.application {} } // Configure KSP to output to the commonMain directory tasks.withType { - doLast { - copy { - from("build/generated/ksp/js/jsMain/kotlin") - into("build/generated/ksp/commonMain/kotlin") - include("**/*.kt") - } - delete("build/generated/ksp/js/jsMain/kotlin") + doLast { + copy { + from("build/generated/ksp/js/jsMain/kotlin") + into("build/generated/ksp/commonMain/kotlin") + include("**/*.kt") } + delete("build/generated/ksp/js/jsMain/kotlin") + delete("build/generated/ksp/desktop/desktopMain/kotlin") + } } \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/AppConfigurationTest.kt b/composeApp/src/commonTest/kotlin/AppConfigurationTest.kt new file mode 100644 index 00000000..d50c776a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/AppConfigurationTest.kt @@ -0,0 +1,37 @@ +import di.AppModule +import io.kotest.matchers.shouldBe +import ivy.di.Di +import kotlin.test.BeforeTest +import kotlin.test.Test + +class AppConfigurationTest { + @BeforeTest + fun setup() { + Di.reset() + Di.init(AppModule) + } + + @Test + fun fakes_should_be_disabled() { + // Given + val appConfiguration = Di.get() + + // When + val fakesEnabled = appConfiguration.fakesEnabled + + // Then + fakesEnabled shouldBe false + } + + @Test + fun should_not_use_local_server() { + // Given + val appConfiguration = Di.get() + + // When + val userLocalServer = appConfiguration.useLocalServer + + // Then + userLocalServer shouldBe false + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt b/composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt index 6900a160..73d4d885 100644 --- a/composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/navigation/SystemNavigation.desktop.kt @@ -1,9 +1,20 @@ package navigation +import kotlinx.coroutines.flow.StateFlow + class DesktopSystemNavigation : SystemNavigation { + override val currentRoute: StateFlow + get() = TODO("Not yet implemented") + override fun navigateTo(screen: Screen) {} - override fun navigateBack() {} - override fun setupUrlChangeListener(onUrlChange: (String, Map) -> Unit) {} + override fun replaceWith(screen: Screen) { + TODO("Not yet implemented") + } + + override fun navigateBack(): Boolean { + TODO("Not yet implemented") + } + } actual fun systemNavigation(): SystemNavigation = DesktopSystemNavigation() \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4ccc18e4..64812a75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -91,6 +91,15 @@ test = [ "kotest-assertions-arrow", "kotlin-coroutines-test" ] +test-kmp = [ + "kotlin-test", + "google-testparameterinjector", + "kotest-assertions", + "kotest-property", + "kotest-property-arrow", + "kotest-assertions-arrow", + "kotlin-coroutines-test" +] ktor-client-common = [ "ktor-client-content-negotiation", "ktor-serialization-json", From 01085eebc39fcd0e1d8c705ea8f7e0343a9ddecb Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 18:56:32 +0200 Subject: [PATCH 2/8] Fix build error --- composeApp/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 53039246..18e2eebb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -119,6 +119,11 @@ tasks.withType { include("**/*.kt") } delete("build/generated/ksp/js/jsMain/kotlin") + copy { + from("build/generated/ksp/desktop/desktopMain/kotlin") + into("build/generated/ksp/commonMain/kotlin") + include("**/*.kt") + } delete("build/generated/ksp/desktop/desktopMain/kotlin") } } \ No newline at end of file From ee957cbc5057261f78a3d425325011cafed3490c Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 20:31:48 +0200 Subject: [PATCH 3/8] Refactor KSP fix --- composeApp/build.gradle.kts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 18e2eebb..97cba83d 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -113,17 +113,22 @@ compose.experimental { // Configure KSP to output to the commonMain directory tasks.withType { doLast { + fixKspConflicts() + } +} + +fun fixKspConflicts() { + fun fixTarget(target: String) { copy { - from("build/generated/ksp/js/jsMain/kotlin") - into("build/generated/ksp/commonMain/kotlin") - include("**/*.kt") - } - delete("build/generated/ksp/js/jsMain/kotlin") - copy { - from("build/generated/ksp/desktop/desktopMain/kotlin") + from("build/generated/ksp/$target/${target}Main/kotlin") into("build/generated/ksp/commonMain/kotlin") include("**/*.kt") } - delete("build/generated/ksp/desktop/desktopMain/kotlin") + delete("build/generated/ksp/$target/${target}Main/kotlin") } + fixTarget("js") + fixTarget("desktop") + fixTarget("android") + fixTarget("ios") + fixTarget("native") } \ No newline at end of file From b68aafcf456fb86398777b4ee80dba4a09f0a0e0 Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 21:03:27 +0200 Subject: [PATCH 4/8] Add KPIs funnel --- .../ivy/learn/domain/analytics/KpiFunnel.kt | 80 +++++++++++++++++++ .../ivy/learn/domain/analytics/KpiService.kt | 31 +++---- .../learn/domain/analytics/kpi/KpiUtils.kt | 8 +- .../kpi/TopCoursesByDistinctViewsKpi.kt | 2 +- .../TopLessonsByDistinctCompletionRateKpi.kt | 7 +- .../kpi/TopLessonsByDistinctCompletionsKpi.kt | 2 +- .../kpi/TopLessonsByDistinctViewsKpi.kt | 2 +- .../ivy/learn/domain/di/DomainModule.kt | 2 + .../ivy/data/source/model/KpisResponse.kt | 1 + 9 files changed, 111 insertions(+), 24 deletions(-) create mode 100644 server/src/main/kotlin/ivy/learn/domain/analytics/KpiFunnel.kt diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/KpiFunnel.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/KpiFunnel.kt new file mode 100644 index 00000000..39ea4464 --- /dev/null +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/KpiFunnel.kt @@ -0,0 +1,80 @@ +package ivy.learn.domain.analytics + +import ivy.data.source.model.KpiDto +import ivy.learn.data.database.tables.AnalyticsTable +import ivy.learn.domain.analytics.kpi.ratioPercent +import ivy.learn.domain.analytics.kpi.totalDistinctCount +import org.jetbrains.exposed.sql.Transaction +import org.jetbrains.exposed.sql.count +import org.jetbrains.exposed.sql.statements.StatementType +import org.jetbrains.exposed.sql.transactions.transaction + +class KpiFunnel { + fun compute(): List = transaction { + // All counts should be distinct by user_id/client_id + val introViews = totalDistinctCount(event = "intro__view") + val enterApp = totalDistinctCount(event = "account__create") + + totalDistinctCount(event = "intro__click_learn_more") + val viewCourse = totalDistinctCount(event = "course__view") + val viewLesson = totalDistinctCount(event = "lesson__view") + val completeLesson = totalDistinctCount("lesson__complete") + val usersCompletedAtLeastTwoLessons = analyticsEventDistinctUsersCountHavingAtLeast( + event = "lesson__complete", + minEventCount = 2 + ) + + listOf( + KpiDto( + name = "Open website", + value = introViews.toString() + ), + KpiDto( + name = "Enter app", + value = "$enterApp (${ratioPercent(enterApp, introViews)})" + ), + KpiDto( + name = "View course", + value = "$viewCourse (${ratioPercent(viewCourse, enterApp)})" + ), + KpiDto( + name = "View lesson", + value = "$viewLesson (${ratioPercent(viewLesson, viewCourse)})" + ), + KpiDto( + name = "Complete lesson", + value = "$completeLesson (${ratioPercent(completeLesson, viewLesson)})" + ), + KpiDto( + name = "Complete 2 lesson", + value = "$usersCompletedAtLeastTwoLessons " + + "(${ratioPercent(usersCompletedAtLeastTwoLessons, completeLesson)})" + ), + ) + } + + private fun Transaction.analyticsEventDistinctUsersCountHavingAtLeast( + event: String, + minEventCount: Int + ): Long { + var usersCount = 0L + exec( + stmt = """ +SELECT count(*) FROM analytics + WHERE event = ? + GROUP by user_id + HAVING count(*) >= ? +""", + args = listOf( + AnalyticsTable.event.columnType to event, + AnalyticsTable.event.count().columnType to minEventCount, + ), + explicitStatementType = StatementType.SELECT, + transform = { rs -> + while (rs.next()) { + usersCount++ + } + } + ) + return usersCount + } +} \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/KpiService.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/KpiService.kt index 8c2c2d3b..85072be0 100644 --- a/server/src/main/kotlin/ivy/learn/domain/analytics/KpiService.kt +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/KpiService.kt @@ -12,6 +12,7 @@ import ivy.model.auth.SessionToken class KpiService( private val authService: AuthService, + private val funnel: KpiFunnel, ) { companion object { val ALLOWED_USERS = setOf( @@ -28,23 +29,23 @@ class KpiService( ServerError.Unauthorized } - val kpis = listOf( - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - Di.get(), - ).map { it.compute() } KpisResponse( - kpis = kpis, + funnel = funnel.compute(), + kpis = listOf( + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + Di.get(), + ).map { it.compute() }, ) } } diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/KpiUtils.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/KpiUtils.kt index 3281b66e..8c98e2ab 100644 --- a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/KpiUtils.kt +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/KpiUtils.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import org.jetbrains.exposed.sql.Transaction import org.jetbrains.exposed.sql.countDistinct import org.jetbrains.exposed.sql.statements.StatementType +import java.text.DecimalFormat @Suppress("unused") fun Transaction.totalDistinctCount(event: String): Long { @@ -51,7 +52,12 @@ SELECT params::TEXT, count(DISTINCT user_id) FROM analytics ) } - fun lessonKpiId(params: Map): String { return "${params[AnalyticsParams.courseId]}/${params[AnalyticsParams.lessonId]}" +} + +fun ratioPercent(a: Long, b: Long): String { + val percentageFormatter = DecimalFormat("0.00") + val ratio = (a / b.toDouble()) * 100 + return "${percentageFormatter.format(ratio)}%" } \ No newline at end of file diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopCoursesByDistinctViewsKpi.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopCoursesByDistinctViewsKpi.kt index b01a0ddd..6ec2dbd4 100644 --- a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopCoursesByDistinctViewsKpi.kt +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopCoursesByDistinctViewsKpi.kt @@ -3,7 +3,7 @@ package ivy.learn.domain.analytics.kpi import ivy.model.analytics.AnalyticsParams class TopCoursesByDistinctViewsKpi : TopItemDistinctUserIdEventCountKpi() { - override val metricName = "Top Courses by distinct user_id views" + override val metricName = "Top Courses by most user views" override val eventName = "course__view" override fun itemId(params: Map): String { return params[AnalyticsParams.courseId]!! diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionRateKpi.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionRateKpi.kt index b0659006..c5c9792d 100644 --- a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionRateKpi.kt +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionRateKpi.kt @@ -2,7 +2,6 @@ package ivy.learn.domain.analytics.kpi import ivy.data.source.model.KpiDto import org.jetbrains.exposed.sql.transactions.transaction -import java.text.DecimalFormat class TopLessonsByDistinctCompletionRateKpi : Kpi { override suspend fun compute(): KpiDto = transaction { @@ -21,14 +20,12 @@ class TopLessonsByDistinctCompletionRateKpi : Kpi { } ) KpiDto( - name = "Top Lessons by distinct user_id completion rate %", + name = "Top Lessons by most user completion rate %", value = buildString { lessonViews.forEach { (lessonId, views) -> if (views > 0) { val completions = lessonCompletions[lessonId] ?: 0 - val percentageFormatter = DecimalFormat("#.00") - val ratio = (completions / views) * 100 - append("$lessonId: ${percentageFormatter.format(ratio)}% ($completions completions / $views views)\n") + append("$lessonId: ${ratioPercent(completions, views)} ($completions completions / $views views)\n") } } } diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionsKpi.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionsKpi.kt index 2711936a..a3cdb227 100644 --- a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionsKpi.kt +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctCompletionsKpi.kt @@ -1,7 +1,7 @@ package ivy.learn.domain.analytics.kpi class TopLessonsByDistinctCompletionsKpi : TopItemDistinctUserIdEventCountKpi() { - override val metricName = "Top Lessons by distinct user_id completions" + override val metricName = "Top Lessons by most user completions" override val eventName = "lesson__complete" override fun itemId(params: Map): String { return lessonKpiId(params) diff --git a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctViewsKpi.kt b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctViewsKpi.kt index 9f8d3cca..803de122 100644 --- a/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctViewsKpi.kt +++ b/server/src/main/kotlin/ivy/learn/domain/analytics/kpi/TopLessonsByDistinctViewsKpi.kt @@ -1,7 +1,7 @@ package ivy.learn.domain.analytics.kpi class TopLessonsByDistinctViewsKpi : TopItemDistinctUserIdEventCountKpi() { - override val metricName = "Top Lessons by distinct user_id views" + override val metricName = "Top Lessons by most user views" override val eventName = "lesson__view" override fun itemId(params: Map): String { return lessonKpiId(params) diff --git a/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt b/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt index 8f2bf0a5..d234fa41 100644 --- a/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt +++ b/server/src/main/kotlin/ivy/learn/domain/di/DomainModule.kt @@ -8,6 +8,7 @@ import ivy.learn.domain.MetricsService import ivy.learn.domain.TopicsService import ivy.learn.domain.analytics.Analytics import ivy.learn.domain.analytics.AnalyticsService +import ivy.learn.domain.analytics.KpiFunnel import ivy.learn.domain.analytics.KpiService import ivy.learn.domain.analytics.kpi.* import ivy.learn.domain.auth.AuthService @@ -36,5 +37,6 @@ object DomainModule : Di.Module { autoWire(::TopLessonsByDistinctCompletionsKpi) autoWire(::AvgLessonViewsPerUserKpi) autoWire(::TopLessonsByDistinctCompletionRateKpi) + autoWire(::KpiFunnel) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/ivy/data/source/model/KpisResponse.kt b/shared/src/commonMain/kotlin/ivy/data/source/model/KpisResponse.kt index 54fd0220..29d5aca0 100644 --- a/shared/src/commonMain/kotlin/ivy/data/source/model/KpisResponse.kt +++ b/shared/src/commonMain/kotlin/ivy/data/source/model/KpisResponse.kt @@ -4,6 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class KpisResponse( + val funnel: List, val kpis: List, ) From 6dc10961600366f738bc8a17100d53396fd781ac Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 21:14:30 +0200 Subject: [PATCH 5/8] Support funnel UI --- .../kotlin/ui/screen/kpi/KpiViewEvent.kt | 5 +++- .../kotlin/ui/screen/kpi/KpiViewModel.kt | 8 ++++++- .../screen/kpi/composable/KpiScreenContent.kt | 24 ++++++++++++++++++- 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewEvent.kt b/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewEvent.kt index 55c9b239..34205b43 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewEvent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewEvent.kt @@ -8,7 +8,10 @@ import kotlinx.collections.immutable.ImmutableList sealed interface KpiViewState { data object Loading : KpiViewState data class Error(val errMsg: String) : KpiViewState - data class Content(val kpis: ImmutableList) : KpiViewState + data class Content( + val funnel: ImmutableList, + val kpis: ImmutableList + ) : KpiViewState } sealed interface KpiViewEvent \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewModel.kt index 2e31ce5a..d00ae7ba 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/kpi/KpiViewModel.kt @@ -21,7 +21,13 @@ class KpiViewModel( return when (val result = res) { is Either.Left -> KpiViewState.Error(result.value) - is Either.Right -> KpiViewState.Content(result.value.kpis.toImmutableList()) + is Either.Right -> { + val kpisResponse = result.value + KpiViewState.Content( + funnel = kpisResponse.funnel.toImmutableList(), + kpis = kpisResponse.kpis.toImmutableList() + ) + } null -> KpiViewState.Loading } } diff --git a/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt index f024528b..6f018d79 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt @@ -2,6 +2,7 @@ package ui.screen.kpi.composable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Text @@ -48,9 +49,17 @@ private fun Content( verticalArrangement = Arrangement.spacedBy(12.dp), horizontalAlignment = Alignment.Start, ) { + sectionDivider(name = "Funnel") itemsIndexed( items = viewState.kpis, - key = { index, item -> "${index}_${item.name}" } + key = { index, item -> "funnel_${index}_${item.name}" } + ) { _, item -> + KpiItem(item = item) + } + sectionDivider(name = "KPIs") + itemsIndexed( + items = viewState.kpis, + key = { index, item -> "kpi_${index}_${item.name}" } ) { _, item -> KpiItem(item = item) } @@ -76,6 +85,19 @@ private fun KpiItem( } } + +private fun LazyListScope.sectionDivider( + name: String, +) { + item(name) { + Spacer(Modifier.height(12.dp)) + Text( + text = name, + style = IvyTheme.typography.h1, + ) + } +} + @Composable private fun ErrorState( viewState: KpiViewState.Error, From b0fce0bdc8b09eac007aa4adef5ceed479648d23 Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 21:15:17 +0200 Subject: [PATCH 6/8] Run unit tests on JS --- .github/workflows/ci_clients.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci_clients.yml b/.github/workflows/ci_clients.yml index 5d8ab653..1352a48c 100644 --- a/.github/workflows/ci_clients.yml +++ b/.github/workflows/ci_clients.yml @@ -65,6 +65,6 @@ jobs: - name: Gradle cache uses: gradle/actions/setup-gradle@v4 - - name: Run unit tests - run: ./gradlew desktopTest + - name: Run unit tests on JS + run: ./gradlew jsTest From 30786c1daab9930b00e5a2aaba81e718919e3fe1 Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 21:18:30 +0200 Subject: [PATCH 7/8] Improve analytics --- .../src/commonMain/kotlin/domain/analytics/Analytics.kt | 4 +--- .../commonMain/kotlin/ui/screen/intro/IntroViewModel.kt | 7 ++++--- .../kotlin/ui/screen/settings/SettingsViewModel.kt | 4 +--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/domain/analytics/Analytics.kt b/composeApp/src/commonMain/kotlin/domain/analytics/Analytics.kt index e1776d6f..44816d08 100644 --- a/composeApp/src/commonMain/kotlin/domain/analytics/Analytics.kt +++ b/composeApp/src/commonMain/kotlin/domain/analytics/Analytics.kt @@ -42,10 +42,8 @@ class Analytics( eventName: String, params: Map?, ) { - if (!enabled) return - appScope.launch { - if (sessionManager.getSession() is Session.LoggedIn) { + if (enabled && sessionManager.getSession() is Session.LoggedIn) { trackLoggedAnalyticsEvent( eventName = eventName, params = params, diff --git a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt index 1f476036..079c70fa 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/intro/IntroViewModel.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.LaunchedEffect import domain.GoogleAuthenticationUseCase import domain.SessionManager import domain.analytics.Analytics -import domain.analytics.Metrics import domain.analytics.Source import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -15,7 +14,6 @@ import ui.screen.home.HomeScreen class IntroViewModel( private val googleAuthenticationUseCase: GoogleAuthenticationUseCase, - private val metrics: Metrics, private val sessionManager: SessionManager, private val analytics: Analytics, private val scope: CoroutineScope, @@ -24,7 +22,10 @@ class IntroViewModel( @Composable override fun viewState(): IntroViewState { LaunchedEffect(Unit) { - metrics.logMetric(name = "intro__view") + analytics.logEvent( + source = Source.Intro, + event = "intro__view" + ) } return IntroViewState() } diff --git a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt index a7085fbf..1f2b691b 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/settings/SettingsViewModel.kt @@ -6,7 +6,6 @@ import data.SoundRepository import domain.DeleteUserDataUseCase import domain.SessionManager import domain.analytics.Analytics -import domain.analytics.Metrics import domain.analytics.Source import ivy.IvyUrls import kotlinx.coroutines.CoroutineScope @@ -27,7 +26,6 @@ class SettingsViewModel( private val analytics: Analytics, private val toaster: Toaster, private val soundRepository: SoundRepository, - private val metrics: Metrics, ) : ComposeViewModel { private var soundEnabled by mutableStateOf(true) private var deleteDialog by mutableStateOf(null) @@ -130,7 +128,7 @@ class SettingsViewModel( deleteDialog = DeleteDialogViewState(ctaLoading = true) deleteUserDataUseCase.execute() deleteDialog = DeleteDialogViewState(ctaLoading = false) - metrics.logMetric(name = "account__deleted") + logEvent(event = "account__deleted") } } From 8db18fcc993f43e19163b03305b20365b83ffea0 Mon Sep 17 00:00:00 2001 From: iliyangermanov Date: Fri, 27 Dec 2024 21:21:39 +0200 Subject: [PATCH 8/8] Improve KPIs UI --- .../kotlin/ui/screen/kpi/composable/KpiScreenContent.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt b/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt index 6f018d79..b104d754 100644 --- a/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt +++ b/composeApp/src/commonMain/kotlin/ui/screen/kpi/composable/KpiScreenContent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -74,13 +75,15 @@ private fun KpiItem( Column(modifier = modifier) { Text( text = item.name, - style = IvyTheme.typography.b1, + style = MaterialTheme.typography.body1, fontWeight = FontWeight.SemiBold, ) Spacer(Modifier.height(4.dp)) Text( text = item.value, - style = IvyTheme.typography.b2 + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, ) } } @@ -94,6 +97,7 @@ private fun LazyListScope.sectionDivider( Text( text = name, style = IvyTheme.typography.h1, + color = MaterialTheme.colors.primary, ) } }