Skip to content

[CLXR-455][Horizon] Implement offline POC#3620

Open
domonkosadam wants to merge 10 commits intofeature/horizon-offlinefrom
CLXR-455-Implement-Offline-architecture
Open

[CLXR-455][Horizon] Implement offline POC#3620
domonkosadam wants to merge 10 commits intofeature/horizon-offlinefrom
CLXR-455-Implement-Offline-architecture

Conversation

@domonkosadam
Copy link
Copy Markdown
Contributor

refs: CLXR-455
affects: Student
release note: none

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 2, 2026

🧪 Unit Test Results


📊 Summary

  • Total Tests: 0
  • Failed: 0
  • Skipped: 0
  • Status: ⚠️ No test results found

Last updated: Thu, 02 Apr 2026 11:57:12 GMT

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Offline-First Dashboard Architecture

This PR introduces a well-structured offline-first layer for the Horizon dashboard — Room database persistence, repository/datasource pattern, use cases, and an offline banner UI. The separation of concerns is clear and the MVVM pattern is consistently followed. A few things worth addressing before this ships:


Issues

  • sync() not implemented in all three repositories (CourseEnrollmentRepository, ModuleItemRepository, ProgramRepository) — TODO("Not yet implemented") throws NotImplementedError at runtime if called. See inline comments.

  • Silent -1 user ID fallback in CourseEnrollmentNetworkDataSource (and duplicated in HorizonOfflineModule) — when apiPrefs.user is null the GraphQL query receives -1, which will produce a silent bad request rather than a clear error. See inline comment.

  • LearningObjectType.valueOf() can crash in ModuleItemLocalDataSource if the database contains a string that doesn't match an enum constant. ProgramLocalDataSource uses safeValueOf for the same pattern — this should match. See inline comment.

  • fallbackToDestructiveMigration() in HorizonDatabaseProvider — this silently deletes all cached data on any schema version bump. There is already a version 2 defined in HorizonDatabase. For an offline-first feature this is high-impact data loss. See inline comment.

  • Typo in OfflineBanner.kt — preview function named OfflineBannerNoDDatePreview should be OfflineBannerNoDatePreview. See inline comment.

  • SyncPolicy enum (SyncPolicy.kt) is defined but never referenced anywhere in this PR. If it's placeholder for future work, a tracking reference would help; otherwise it's dead code.

  • Missing unit tests for the new offline logic: OfflineSyncRepository.shouldFetchFromNetwork() decision branches, CourseEnrollmentRepository returning cached data when offline, GetDashboardCoursesUseCase orchestration (especially the invitation acceptance flow), and DashboardMapper changes for DashboardEnrollment.


Minor

  • The auto-invite-acceptance behaviour is carried over from the old DashboardCourseViewModel — noted in an inline comment purely for documentary purposes, no change required.
  • HorizonOfflineModule also uses apiPrefs.user?.id ?: -1L when providing the HorizonDatabase instance — same concern as the datasource fallback.
  • No newline at end of file in HorizonTypeConverters.kt and HorizonSyncMetadataEntity.kt.

Overall the architecture is solid and the refactor from ViewModel-level data fetching to proper use cases is a good step forward. The main blockers are the unimplemented sync() methods and the enum crash risk in ModuleItemLocalDataSource.

}

override suspend fun sync() {
TODO("Not yet implemented")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO will throw a NotImplementedError at runtime if sync() is ever called. The base class OfflineSyncRepository defines this as an abstract method that is part of the offline sync contract. Either implement it or, if intentionally deferred, throw a more informative exception or leave a tracked issue reference here so it doesn't silently ship as dead code.

override suspend fun sync() {
    // TODO: Implement full enrollment sync - tracked in ISSUE-XXXX
    throw UnsupportedOperationException("Enrollment sync not yet implemented")
}

}

override suspend fun sync() {
TODO("Not yet implemented")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern as in CourseEnrollmentRepositoryTODO("Not yet implemented") will crash at runtime with a NotImplementedError if sync() is invoked. Please either implement or explicitly mark as deferred with a tracking reference.

}

override suspend fun sync() {
TODO("Not yet implemented")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same concern as other repositories — TODO("Not yet implemented") will crash at runtime. Please implement or add a tracked issue reference.


suspend fun getEnrollments(): List<DashboardEnrollment> {
return horizonGetCoursesManager.getDashboardEnrollments(
userId = apiPrefs.user?.id ?: -1,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apiPrefs.user?.id ?: -1 will silently pass -1 as a user ID to the GraphQL query when the user is not logged in, likely producing an empty or error response that is hard to debug. Consider throwing instead so callers can handle the unauthenticated case explicitly:

userId = apiPrefs.user?.id ?: error("User must be logged in to fetch enrollments"),

moduleItemId = entity.moduleItemId,
courseId = entity.courseId,
title = entity.moduleItemTitle,
type = LearningObjectType.valueOf(entity.moduleItemType),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LearningObjectType.valueOf(entity.moduleItemType) will throw IllegalArgumentException if the stored string doesn't match an enum constant — for example after a future enum rename or if the database already contains a value from a schema that predates this one. ProgramLocalDataSource uses safeValueOf for the same pattern; please apply the same defensive approach here:

type = LearningObjectType.safeValueOf(entity.moduleItemType),

(or filter out rows with unknown types before mapping them)

fun getDatabase(userId: Long): HorizonDatabase {
return dbMap.getOrPut(userId) {
Room.databaseBuilder(context, HorizonDatabase::class.java, "horizon-db-$userId")
.fallbackToDestructiveMigration()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallbackToDestructiveMigration() will silently wipe all cached offline data when the schema version is bumped (e.g. version 1 → 2 already exists in HorizonDatabase). For an offline-first feature this means users lose their cached content on every schema change without warning. If full migration scripts aren't ready yet, at a minimum document this decision and track the migration work. A lightweight migration that just drops and recreates tables can also be written to make the data-loss intent explicit rather than accidental.


@Preview
@Composable
private fun OfflineBannerNoDDatePreview() {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typo in preview function name: NoDDate should be NoDate.

private fun OfflineBannerNoDatePreview() {


val invitations = enrollments.filter { it.enrollmentState == DashboardEnrollment.STATE_INVITED }
if (invitations.isNotEmpty()) {
invitations.forEach { enrollment ->
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Invitations are being accepted automatically without user confirmation. This was already the behaviour in DashboardCourseViewModel before this refactor, so the behaviour isn't changing — but it's worth flagging since this is now more prominently a domain-layer concern. If silent auto-acceptance is intentional per product spec, a brief comment here would help future readers understand why (e.g. "Canvas Career automatically accepts all pending invitations on dashboard load").

): ViewModel() {
private val getDashboardCoursesUseCase: GetDashboardCoursesUseCase,
private val dashboardEventHandler: DashboardEventHandler,
networkStateProvider: NetworkStateProvider,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use composition instead. Creating a class that encapsulates the behavior of the HorizonOfflineViewModel, in that case you won't need to pass all the parameters to the ViewModels instead just pass that new class.

private val networkStateProvider: NetworkStateProvider,
private val featureFlagProvider: FeatureFlagProvider,
private val getLastSyncedAtUseCase: GetLastSyncedAtUseCase,
) : ViewModel() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we use HorizonOfflineViewModel here as well?

private val syncMetadataDao: HorizonSyncMetadataDao,
) : BaseUseCase<SyncDataType, Long?>() {

override suspend fun execute(params: SyncDataType): Long? = syncMetadataDao.getLastSyncedAt(params)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remember correctly we agreed with Ákos to have the params as an inner class of the Use Case as a convention. I am not sure if this is applicable where we have a single parameter only, but I see some occurrences that suggest this, for example GetAuthenticatedSessionUseCase. What do you think @hermannakos ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I would go with the inner class for consistency at least. We didn't follow this convention at first since we didn't have that, so there might be some where it is not followed


class ProgramRepository @Inject constructor(
private val networkDataSource: ProgramNetworkDataSource,
private val localDataSource: ProgramLocalDataSource,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we have a data source interface like in the current offline implementation?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants