Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2e806e3
feat(agenda): add daily deadline cutoff window
PurpleRain-PR Nov 9, 2026
35c6aff
fix(agenda): shorten adjusted deadline label and test timetable badges
PurpleRain-PR Nov 9, 2026
c0eb179
feat(agenda): configure global deadline layout days
PurpleRain-PR Nov 10, 2026
45382d4
feat(settings): keep the native deadline clock resident
PurpleRain-PR Nov 10, 2026
150af82
feat(agenda): unify event and exam timeline styling
PurpleRain-PR Nov 10, 2026
90f0f69
feat(agenda): persist completion and one-shot exam auto completion
PurpleRain-PR Nov 10, 2026
ae2db6b
fix(academics): keep exam time filters independent of completion
PurpleRain-PR Nov 10, 2026
eac5320
fix(test): isolate completion diagnostics and update state expectations
PurpleRain-PR Nov 10, 2026
9248f2e
fix(test): keep completion diagnostics IO inside the test clock
PurpleRain-PR Nov 10, 2026
7b0cbc4
fix(test): declare diagnostics IO override final
PurpleRain-PR Nov 10, 2026
4acce20
refactor(agenda): inject completion error reporting for deterministic…
PurpleRain-PR Nov 10, 2026
8264b8d
merge: integrate main optimizations with deadline cutoff features
PurpleRain-PR Nov 11, 2026
0f13348
fix(agenda): initialize stable layout card span directly
PurpleRain-PR Nov 11, 2026
af486c3
fix(test): isolate merge cache updates and badge clock
PurpleRain-PR Nov 11, 2026
08ed417
docs(agenda): align merged architecture with feature semantics
PurpleRain-PR Nov 11, 2026
6c5ebe1
feat(agenda): make the event information bar optional and restyle it
PurpleRain-PR Nov 11, 2026
d769f89
feat(agenda): band event intervals and keep the hidden start line
PurpleRain-PR Nov 11, 2026
788caac
test(settings): scroll the lazy settings list before the info bar switch
PurpleRain-PR Nov 11, 2026
a421043
fix(agenda): compute band color before the cascade
PurpleRain-PR Nov 11, 2026
188d84f
test(agenda): compare band paint starts without DateTime <
PurpleRain-PR Nov 11, 2026
ef574ec
merge: land the deadline cutoff window and event timeline styling
PurpleRain-PR Nov 11, 2026
73b6b3b
fix(agenda): restore event cards and per-item deadline tails
dongdigua Nov 12, 2026
34bbb87
feat(settings): add manual migration for default DDL tails
dongdigua Nov 12, 2026
619fe86
refactor(agenda): share timeline captions and tail day bounds
dongdigua Nov 12, 2026
a8cdfaf
Merge branch 'main' into fix/ddl-ui-and-tail-settings
dongdigua Nov 13, 2026
753042d
Merge pull request #36 from bitoolkit/fix/ddl-ui-and-tail-settings
dongdigua Nov 13, 2026
86f24cc
Initial plan
Copilot Nov 13, 2026
fbf681d
Fix deadline cutoff time picker build
Copilot Nov 13, 2026
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
35 changes: 28 additions & 7 deletions android/app/src/main/kotlin/dev/so1ve/bitree/WidgetAgenda.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,11 @@ internal data class WidgetContent(
val nextUpdate: Long,
)

internal fun readWidgetContent(context: Context): WidgetContent {
val now = Calendar.getInstance(campusZone)
internal fun readWidgetContent(
context: Context,
nowMillis: Long = System.currentTimeMillis(),
): WidgetContent {
val now = Calendar.getInstance(campusZone).apply { timeInMillis = nowMillis }
val today = dayStart(now.timeInMillis)
val tomorrow = (today.clone() as Calendar).apply { add(Calendar.DATE, 1) }
val evening = (today.clone() as Calendar).apply { set(Calendar.HOUR_OF_DAY, 21) }
Expand All @@ -81,15 +84,24 @@ internal fun readWidgetContent(context: Context): WidgetContent {
val todayHeading = "今天${weekdays[weekday(today) - 1]}"

try {
val entries = readCourseEntries(context, listOf(today, tomorrow)) + readAgendaEntries(context)
val entries = readCourseEntries(context, listOf(today, tomorrow)) + readAgendaEntries(context, now.timeInMillis)
fun entriesOn(date: Calendar) = entries.filter { it.occursOn(date) }
.sortedWith(compareBy({ !it.allDay }, { it.startsAt }, { it.kind }, { it.title }))

val todayEntries = entriesOn(today)
val minute = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
val remaining = todayEntries.filter { it.endsAt > now.timeInMillis }
val remaining = todayEntries.filter {
when (it.kind) {
WidgetEntryKind.EVENT, WidgetEntryKind.EXAM -> true
else -> it.endsAt > now.timeInMillis
}
}
for (entry in remaining) {
if (entry.endsAt < nextUpdate) nextUpdate = entry.endsAt
// Overdue agenda entries remain visible; past boundaries must never
// create an immediate widget-update loop. Courses keep the old rule.
if (entry.endsAt > now.timeInMillis && entry.endsAt < nextUpdate) {
nextUpdate = entry.endsAt
}
}
val showTomorrow = minute >= 21 * 60 && remaining.isEmpty()
val displayed = if (showTomorrow) entriesOn(tomorrow) else remaining
Expand Down Expand Up @@ -151,7 +163,7 @@ private fun readCourseEntries(context: Context, dates: List<Calendar>): List<Wid
}
}

private fun readAgendaEntries(context: Context): List<WidgetEntry> {
private fun readAgendaEntries(context: Context, now: Long): List<WidgetEntry> {
val file = File(context.filesDir, "agenda.json")
if (!file.exists()) return emptyList()

Expand All @@ -162,7 +174,7 @@ private fun readAgendaEntries(context: Context): List<WidgetEntry> {

return (0 until rows.length()).map { rows.getJSONObject(it) }
.filter { !it.getBoolean("done") && !it.optBoolean("deleted") }
.map { row ->
.mapNotNull { row ->
val kind = WidgetEntryKind.valueOf(row.getString("kind").uppercase(Locale.ROOT))
val start = timestamp(row.getString("startsAt"))
val allDay = row.getBoolean("allDay")
Expand All @@ -171,6 +183,15 @@ private fun readAgendaEntries(context: Context): List<WidgetEntry> {
allDay -> dayStart(start).apply { add(Calendar.DATE, 1) }.timeInMillis
else -> start
}
// Read-only fallback while Flutter is stopped. Only Dart persists
// the one-shot marker; reopening an exam keeps it visible as overdue.
if (kind == WidgetEntryKind.EXAM &&
!row.optBoolean("examAutoCompletionHandled") && end <= now
) return@mapNotNull null
// Match the one-time legacy event migration without writing JSON.
if (kind == WidgetEntryKind.EVENT &&
row.optInt("completionSchemaVersion", 0) < 1 && end <= now
) return@mapNotNull null
WidgetEntry(
title = row.getString("title"),
kind = kind,
Expand Down
75 changes: 75 additions & 0 deletions android/app/src/test/kotlin/dev/so1ve/bitree/WidgetAgendaTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import org.json.JSONArray
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
Expand Down Expand Up @@ -89,6 +90,80 @@ class WidgetAgendaTest {
)
}

@Test
fun versionedAgendaRetainsOverdueEventsAndReopenedExamsWithoutWriting() {
val now = timestamp("2026-11-09T12:00:00.000Z")
val entries = JSONArray()
.put(timed("逾期日程", "event", 1))
.put(timed("已撤销考试", "exam", 1).put("examAutoCompletionHandled", true))
.put(timed("尚未补账考试", "exam", 1))
.put(timed("旧历史日程", "event", 0))
.put(timed("已完成日程", "event", 1).put("done", true))
.put(timed("已删除日程", "event", 1).put("deleted", true))
val original = JSONObject().put("schemaVersion", 2)
.put("entries", entries).toString()
agendaFile.writeText(original)

val content = readWidgetContent(context, now)

assertEquals(setOf("逾期日程", "已撤销考试"), content.entries.map { it.title }.toSet())
assertTrue(content.nextUpdate > now)
assertEquals(original, agendaFile.readText())
}

@Test
fun overdueAgendaDoesNotChangeCourseFilteringOrScheduleRefreshesInThePast() {
val now = timestamp("2026-11-09T12:00:00.000Z")
val courses = JSONArray()
.put(course("已下课", 1))
.put(course("下午课", 6))
val term = JSONObject()
.put("firstMonday", "2026-11-09")
.put("courses", courses)
File(context.filesDir, "schedule.json").writeText(
JSONObject().put("schemaVersion", 2).put("selectedTerm", "term")
.put("semesters", JSONObject().put("term", term)).toString(),
)
agendaFile.writeText(
JSONObject().put("schemaVersion", 2)
.put("entries", JSONArray().put(timed("逾期日程", "event", 1)))
.toString(),
)

val content = readWidgetContent(context, now)

assertEquals(listOf("逾期日程", "下午课"), content.entries.map { it.title })
assertEquals(timestamp("2026-11-09T14:05:00.000Z"), content.nextUpdate)
assertTrue(content.nextUpdate > now)
}

private fun timed(title: String, kind: String, version: Int): JSONObject = JSONObject()
.put("id", title)
.put("title", title)
.put("kind", kind)
.put("startsAt", "2026-11-09T09:00:00.000Z")
.put("endsAt", "2026-11-09T11:00:00.000Z")
.put("allDay", false)
.put("location", "")
.put("done", false)
.put("deleted", false)
.put("completionSchemaVersion", version)
.put("examAutoCompletionHandled", false)

private fun course(name: String, section: Int): JSONObject = JSONObject()
.put("name", name)
.put("room", "")
.put("campus", "")
.put("weekday", 1)
.put("startSection", section)
.put("endSection", section)
.put("weeks", JSONArray().put(1))

private fun timestamp(value: String): Long =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ROOT)
.apply { timeZone = TimeZone.getTimeZone("Asia/Shanghai") }
.parse(value)!!.time

private fun deadline(title: String): JSONObject {
val zone = TimeZone.getTimeZone("Asia/Shanghai")
val today = Calendar.getInstance(zone).apply {
Expand Down
67 changes: 67 additions & 0 deletions docs/agenda-completion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 日程完成与考试自动一次

## 完成和时间结束分离

`done` 表示真正的完成选择;`isFinished(now)` 保持旧调用接口但统一返回
`done`,不再用时间经过代替完成。`hasEnded(now)` 单独表示时间边界已到。

- DDL 沿用原有完成规则;逾期使用有效截止(含伪提前),仍是严格晚于截止。
- 仅开始时间的 DDL 可以完成,但没有截止时间,永不逾期。
- 普通日程不会自动完成;到真实结束时仍未完成即为逾期。
- 考试到真实结束时自动完成一次。用户手动完成或撤销也会消费这次自动机会,
因而提前手动完成再撤销也不会在结束时覆盖人工选择。
- 考试自动完成后撤销,保持未完成并在真实结束后显示逾期;构建、重启、
同 ID 同步和编辑不会再次自动完成。

伪提前只用于 DDL,不移动普通日程或考试的真实时间。

## 持久化与迁移

新增 `examAutoCompletionHandled`(默认 false)记忆自动机会是否消费,
`completionSchemaVersion`(当前 1)标识一次性历史迁移。新建日程直接使用
当前版本;旧 JSON 缺字段时读取为版本 0,解析本身不调用当前时钟。

控制器在加载后的可控时钟处迁移并原子保存:旧已结束普通日程标为完成以
保留原“已结束”历史效果;旧未来普通日程只升级版本,不自动完成。旧已完成
考试消费自动机会;未处理的到期考试原子写入 `done=true` 和 handled=true。
版本写回后,撤销不会被下一次加载当作旧数据重新完成。

自动与人工操作共用串行队列。人工操作使用 ID 和目标完成值更新最新记录,
避免旧 UI 快照覆盖同步元数据,也不在排队结束时反转最新值。写入失败不
发布已消费标记,保留重试机会;未处理的考试在补账失败期间不显示逾期。

## 时钟与生命周期

生产环境由 AppController 激活前台完成时钟,AgendaController 只持有一个
定时器,指向最近未处理考试的结束时间。暂停/后台取消,恢复前台补账;
关闭 App 后错过的结束时间在下次加载补处理,无需常驻后台任务。失败时
前台最短 30 秒重试,不能对过去的结束时间建立零延时写入循环。

测试可注入时钟。通用 TestAppController 默认关闭后台计时,生产时钟测试
显式开启并在测试正文结束前销毁,不能依赖晚于 Flutter 校验的 teardown。

## 同步、编辑与边界

同 ID 学校考试刷新时间、地点等元数据时,保留本地 done、handled 和版本;
单纯完成不把整条记录标为 local,避免阻断之后的远端内容更新。编辑器保留
这些状态字段。DDL 来源原有的远端提交状态策略不改变。

当前学校考试 ID 包含开始时间,远端改开始时间会生成新 ID。在没有可靠
稳定业务 ID 时不按标题猜配,多场考试不能误合并。因此当前只保证可匹配
的存续同 ID 记录保留选择;不同 ID 是新记录,物理删除/远端消失后重现也
没有额外的完成 ledger。扩展跨 ID 保留必须先取得可靠身份和迁移策略。

列表、详情、课表角标及提醒使用统一完成/逾期状态;学业考试页“过去考试”
和冲突判断仍使用真实时间是否结束,不能因提前手动完成而隐藏未来冲突。
非 DDL 删除仍采用原来的物理删除+即时撤销,不扩展 DDL 专用回收站范围。

Android 日程小组件只读 agenda.json:未完成逾期日程和已撤销考试不因
结束时间再次被隐藏;未处理到期考试和旧历史日程可只读派生原兼容状态,
仅 Dart 持久化迁移,不让两个运行时同时重写 JSON。学校课程下课过滤不变,
下一次刷新只选未来边界,避免过去时间造成刷新循环。

## 回归

覆盖模型/JSON、旧数据迁移一次、考试自动一次及撤销重启、人工/自动排队、
同 ID 同步、失败重试与退避、生命周期、编辑保留、详情/列表完成撤销和
非 DDL 删除恢复。无本地 Flutter SDK 时,不将静态审查和推送表述为测试通过。
Loading