为什么习惯不能只有一个删除按钮

用户不再继续“每天阅读”时,可能有两种完全不同的意图:

暂时不做了,但想保留历史

适合归档。

这个习惯建错了,希望连历史一起删除

适合删除。

如果产品只有删除,用户为了整理今日列表就必须牺牲历史;如果所谓归档只是隐藏 UI,但统计仍持续把未来日期算进分母,数据又会越来越失真。

一、Habit 模型需要表达生命周期

export interface Habit {
  id: string;
  name: string;
  icon: string;
  color: string;
  isActive: boolean;
  createdAtMillis: number;
  updatedAtMillis: number;
  archivedAtMillis: number;
}

其中:

  • isActive:当前是否显示在今日打卡;
  • createdAtMillis:从哪天开始参与统计;
  • archivedAtMillis:从哪天停止参与;
  • updatedAtMillis:列表刷新和审计使用。

二、今日页只关心 activeHabits

private activeHabits(): Habit[] {
  return this.habits.filter(
    (habit: Habit) => habit.isActive
  );
}

归档列表则相反:

private archivedHabits(): Habit[] {
  return this.habits.filter(
    (habit: Habit) => !habit.isActive
  );
}

归档改变的是“当前是否继续参与”,不是删除实体。

三、归档时要记录发生日期

private setHabitActive(
  habitId: string,
  active: boolean
): void {
  const now: number = Date.now();

  this.habits = this.habits.map((habit: Habit) => {
    if (habit.id !== habitId) {
      return habit;
    }

    return {
      id: habit.id,
      name: habit.name,
      icon: habit.icon,
      color: habit.color,
      isActive: active,
      createdAtMillis: habit.createdAtMillis,
      updatedAtMillis: now,
      archivedAtMillis: active ? 0 : now
    };
  });

  this.persist();
  this.refreshUi();
}

归档:

isActive = false
archivedAtMillis = now

恢复:

isActive = true
archivedAtMillis = 0

四、历史打卡为什么不能随归档删除

HabitCompletion 独立保存:

export interface HabitCompletion {
  id: string;
  habitId: string;
  dayIdentifier: string;
  isCompleted: boolean;
  // 时间字段
}

归档时不修改 habitCompletions,因此历史月历和最近记录仍能找到过去的完成情况。

这是归档的核心承诺:

停止未来展示,保留过去事实

五、统计分母只计算有效生命周期

假设一个习惯 8 月 10 日创建,8 月 15 日归档。查看最近 30 天时,不应该把 30 天全部当作可完成天数。

项目逐日构造可参与日期:

if (
  dayStart >= createdStart &&
  (
    archivedStart === 0 ||
    dayStart <= archivedStart
  )
) {
  identifiers.push(dayIdentifier(day));
}

因此分母只包含:

创建日 ≤ 日期 ≤ 归档日

新建习惯不会为创建前的日期得到大量“未完成”,归档后也不会继续拉低完成率。

六、恢复后单个 archivedAtMillis 有什么局限

当前模型恢复时把 archivedAtMillis 清零。

如果一个习惯经历:

创建 → 归档 10 天 → 恢复

恢复后,模型无法再知道中间曾暂停 10 天。统计会把整个创建日至今都视为有效期。

对于第一版轻量习惯模型,这个取舍可以接受;如果产品支持频繁暂停/恢复,就需要更完整的区间模型:

interface HabitActivePeriod {
  habitId: string;
  startDay: string;
  endDay: string;
}

或者保存状态变更事件:

ACTIVATED
ARCHIVED
RESTORED

统计时根据事件重建有效区间。

这说明时间字段设计要与产品承诺匹配。只存最后一次归档时间,不能支持无限复杂的历史解释。

七、删除必须级联清理关联打卡

删除实现:

private deleteHabit(habitId: string): void {
  this.habits = this.habits.filter(
    (habit: Habit) => habit.id !== habitId
  );

  this.completions = this.completions.filter(
    (completion: HabitCompletion) =>
      completion.habitId !== habitId
  );

  this.showDeleteHabitId = '';
  this.persist();
  this.refreshUi();
}

如果只删除 Habit,会留下孤儿 Completion:

  • 历史日期仍可能出现打卡数量;
  • 统计可能计算一个不存在的习惯;
  • JSON 导出包含无法解释的 habitId;
  • 以后导入数据库时破坏引用完整性。

本地数组模型没有数据库外键,因此级联规则必须由 Repository 或业务层主动保证。

八、删除前为什么要二次确认

归档可恢复,删除不可恢复,交互强度应不同。

删除确认文案需要说明影响范围:

习惯及相关打卡会永久删除;如果以后可能继续,建议选择归档。

它比“确定删除吗?”提供了更有价值的信息:

  • 告诉用户会删除关联打卡;
  • 提醒存在更温和的归档选项;
  • 让不可逆操作不容易误触。

九、删除全部数据与删除单个习惯不同

删除全部数据会清空:

this.moodEntries = [];
this.habits = [];
this.completions = [];

并保持“默认习惯已经初始化过”的标志,避免重启后自动复活。

删除单个习惯只影响该 Habit 和关联 Completion,不应该删除当天的心情日记或其他习惯。

不同删除范围必须在函数和文案中分开,不要复用一个含糊的 clear()

十、什么时候应该提供撤销

如果用户误删成本高,可以考虑:

软删除

增加 deletedAtMillis,一段时间后物理清理。

操作后撤销

短时间保留被删除对象和关联打卡,Toast 提供“撤销”。

导出后删除

删除全部数据前提示先导出 JSON。

但不要只在 UI 隐藏记录,却在隐私文案中声称已经永久删除。产品语义、磁盘状态和用户承诺必须一致。

十一、生命周期测试清单

归档

  • 今日页立即消失;
  • 历史打卡仍存在;
  • 归档列表出现;
  • 统计截止到归档日;
  • 杀进程后仍归档。

恢复

  • 今日页重新出现;
  • 历史打卡仍存在;
  • 新打卡可以创建;
  • 统计口径符合当前模型定义。

删除

  • 二次确认可取消;
  • Habit 删除;
  • 关联 Completion 全部删除;
  • 其他习惯不受影响;
  • 历史日期和统计同步刷新;
  • 导出 JSON 不再含孤儿引用。

总结

归档、恢复和删除的区别可以归纳为:

操作 今日展示 历史打卡 统计未来分母 可恢复
归档 移除 保留 停止计入
恢复 恢复 保留 重新计入
删除 移除 删除 不再存在

只有当数据模型、统计口径和交互文案同时遵守这张表,习惯生命周期才真正一致。

本文案例来自“心晴手记(MoodMemoir)”HarmonyOS 版的习惯管理与统计实现。


Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐