feat(data): add TeacherScheduleRepositoryImpl with bulk period sync

This commit is contained in:
2026-09-19 19:12:19 +03:00
parent 9dce30931b
commit 6c06892d9a
@@ -0,0 +1,228 @@
package agzam.vyatsu.data.repository
import agzam.vyatsu.data.local.DepartmentPeriodEntity
import agzam.vyatsu.data.local.TeacherDao
import agzam.vyatsu.data.local.TeacherDayEntity
import agzam.vyatsu.data.local.TeacherLessonEntity
import agzam.vyatsu.data.local.TeacherScheduleDao
import agzam.vyatsu.data.local.TeacherScheduleEntity
import agzam.vyatsu.data.remote.TeacherScheduleParser
import agzam.vyatsu.data.remote.VyatsuHttpClient
import agzam.vyatsu.data.remote.VyatsuUrls
import agzam.vyatsu.domain.model.BulkSyncProgress
import agzam.vyatsu.domain.model.DateRange
import agzam.vyatsu.domain.model.Day
import agzam.vyatsu.domain.model.Lesson
import agzam.vyatsu.domain.model.SchedulePeriod
import agzam.vyatsu.domain.model.Teacher
import agzam.vyatsu.domain.model.TeacherSchedule
import agzam.vyatsu.domain.model.TimeSlot
import agzam.vyatsu.domain.repository.TeacherScheduleRepository
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.LocalTime
class TeacherScheduleRepositoryImpl(
private val scheduleDao: TeacherScheduleDao,
private val teacherDao: TeacherDao
) : TeacherScheduleRepository {
override fun observeSchedule(period: SchedulePeriod): Flow<TeacherSchedule?> =
combine(
scheduleDao.observeDaysForPeriod(period.season.name, period.weekStart.toEpochDay()),
scheduleDao.observeLessonsForPeriod(period.season.name, period.weekStart.toEpochDay())
) { days, lessons ->
buildTeacherSchedule(period, days, lessons)
}
override fun observeTeachers(period: SchedulePeriod): Flow<List<Teacher>> =
scheduleDao.observeDistinctTeachers(period.season.name, period.weekStart.toEpochDay())
.map { names -> names.map { name -> Teacher(name = name) } }
override suspend fun syncPeriod(date: LocalDate): Flow<BulkSyncProgress> = flow {
val targets = withContext(Dispatchers.IO) {
teacherDao.getPeriodsForDate(date.toEpochDay())
}
if (targets.isEmpty()) {
emit(BulkSyncProgress(0, 0, 0))
return@flow
}
targets.map { it.season to it.weekStart }
.distinct()
.forEach { (season, weekStart) ->
withContext(Dispatchers.IO) {
scheduleDao.deletePeriod(season.name, weekStart)
}
}
val total = targets.size
val semaphore = Semaphore(PARALLELISM)
val jobs = coroutineScope {
targets.map { target ->
async {
semaphore.withPermit {
try {
withContext(Dispatchers.IO) { syncDepartment(target) }
true
} catch (e: Throwable) {
Log.e(
TAG,
"sync: failed for department ${target.departmentId} " +
"season ${target.season} week ${target.weekStart} " +
"(${e::class.simpleName}: ${e.message})",
e
)
false
}
}
}
}
}
var done = 0
var failed = 0
for (job in jobs) {
if (job.await()) done++ else failed++
emit(BulkSyncProgress(total, done + failed, failed))
}
withContext(Dispatchers.IO) { trimToRecent(keepDistinctPeriods = 2) }
}
override suspend fun hasData(period: SchedulePeriod): Boolean =
withContext(Dispatchers.IO) {
scheduleDao.hasSchedulesForPeriod(period.season.name, period.weekStart.toEpochDay())
}
override suspend fun clearAll() = withContext(Dispatchers.IO) {
scheduleDao.deleteAll()
}
private suspend fun syncDepartment(target: DepartmentPeriodEntity) {
val season = target.season
val range = DateRange(
LocalDate.ofEpochDay(target.weekStart).format(RANGE_FMT),
LocalDate.ofEpochDay(target.weekEnd).format(RANGE_FMT)
)
val url = VyatsuUrls.teacherPrepodUrl(target.departmentId, season, range)
val html = VyatsuHttpClient.getBytes(url).toString(Charsets.UTF_8)
val parsed = TeacherScheduleParser.parse(html)
val scheduleId = scheduleDao.insertSchedule(
TeacherScheduleEntity(
departmentId = target.departmentId,
season = season,
weekStart = target.weekStart,
weekEnd = target.weekEnd,
lastSyncedAt = now()
)
)
replaceDays(scheduleId, parsed.days)
}
private suspend fun replaceDays(scheduleId: Long, days: List<Day>) {
scheduleDao.deleteDaysForSchedule(scheduleId)
val dayEntities = days.map {
TeacherDayEntity(scheduleId = scheduleId, date = it.date.toEpochDay())
}
val dayIds = scheduleDao.insertDays(dayEntities)
val dayIdByDate = dayEntities.zip(dayIds).associate { (day, id) ->
LocalDate.ofEpochDay(day.date) to id
}
val lessons = days.flatMap { day ->
day.intervals.flatMapIndexed { slotIndex, slot ->
slot.lessons.mapIndexedNotNull { lessonIndex, lesson ->
val teacher = lesson.teacher ?: return@mapIndexedNotNull null
TeacherLessonEntity(
dayId = dayIdByDate.getValue(day.date),
date = day.date.toEpochDay(),
slotIndex = slotIndex,
start = slot.start.toMinutesOfDay(),
end = slot.end.toMinutesOfDay(),
lessonIndex = lessonIndex,
raw = lesson.raw,
room = lesson.room,
groupName = lesson.group,
subgroup = lesson.subgroup,
subject = lesson.subject,
type = lesson.type,
teacher = teacher
)
}
}
}
scheduleDao.insertLessons(lessons)
}
private suspend fun trimToRecent(keepDistinctPeriods: Int) {
val schedules = scheduleDao.getSchedules()
schedules
.map { it.season to it.weekStart }
.distinct()
.sortedByDescending { it.second }
.drop(keepDistinctPeriods)
.forEach { (season, weekStart) ->
scheduleDao.deletePeriod(season.name, weekStart)
}
}
private fun buildTeacherSchedule(
period: SchedulePeriod,
dayEntities: List<TeacherDayEntity>,
lessonEntities: List<TeacherLessonEntity>
): TeacherSchedule? {
if (dayEntities.isEmpty()) return null
val lessonsByDate = lessonEntities.groupBy { it.date }
val days = dayEntities
.sortedBy { it.date }
.distinctBy { it.date }
.map { day ->
val lessons = lessonsByDate[day.date].orEmpty()
.groupBy { it.slotIndex }
.toSortedMap()
.map { (_, slotLessons) ->
TimeSlot(
start = LocalTime.of(slotLessons.first().start / 60, slotLessons.first().start % 60),
end = LocalTime.of(slotLessons.first().end / 60, slotLessons.first().end % 60),
lessons = slotLessons
.sortedBy { it.lessonIndex }
.map { it.toDomain() }
)
}
Day(date = LocalDate.ofEpochDay(day.date), intervals = lessons)
}
return TeacherSchedule(period = period, days = days)
}
private fun TeacherLessonEntity.toDomain(): Lesson = Lesson(
raw = raw,
room = room,
group = groupName,
subgroup = subgroup,
subject = subject,
type = type,
teacher = teacher
)
private fun LocalTime.toMinutesOfDay(): Int = hour * 60 + minute
private fun now(): Long = System.currentTimeMillis()
companion object {
private const val STALE_WINDOW_MS = 12 * 60 * 60 * 1000L
private const val PARALLELISM = 5
private const val TAG = "TeacherScheduleRepo"
private val RANGE_FMT = java.time.format.DateTimeFormatter.ofPattern("ddMMyyyy")
}
}