반복 할일은 생성일 기준, 기한은 간격을 유지

2026-09-23 23:42 (KST). 대상은 안드로이드 day_plan_app 이다. 웹 app_schedule 은 이미 이 규칙이다. 이 컴퓨터의 앱 트리는 참조용이라 여기서 고치지 않는다.

1. 규칙

반복 할일의 달력 칸과 회차 일자는 기한이 아니라 생성일이다. 기한은 생성일과의 간격만 유지한다.

상태생성일기한칸
이번 주. 생성 9월 24일, 기한 9월 25일, 매주 2026-09-24 2026-09-25 9월 24일
다음 주 2026-10-01 (생성일 +7) 2026-10-02 (새 생성일 +1일) 10월 1일
그다음 주 2026-10-08 2026-10-09 10월 8일
할일 저장 때 기한이 비어 있음 고른 생성일 생성일과 같은 날 (간격 0) 생성일

간격 = 기한 날짜 − 생성일 날짜. 기한이 없으면 0이다. 매일이면 +1일, 매주면 +7일, 매달·매년도 같은 방식으로 생성일을 밀고 기한은 새 생성일 + 간격이다.

지금 앱 Todo.repeatAnchor 는 할일의 기준일을 dueDate 로 본다. rollForwardRepeatTodos 도 기한만 다음 회차로 옮기고 createdAt 은 복사만 한다. 그래서 생성일 9월 24일, 기한 9월 25일, 매주면 칸이 25일에 붙고, 다음 주도 기한만 +7 된다.

2. 기준일을 생성일로

파일 lib/models/todo.dart 의 repeatAnchor 를 바꾼다. 순수 일정은 지금처럼 생성일. 반복 할일도 생성일. 기록은 기한.

/// 반복 기준일. 순수 일정과 반복 할일은 생성일. 기록은 기한.
String? get repeatAnchor {
  final created = createdAt;
  final createdDay = (created != null && created.length >= 10)
      ? created.substring(0, 10)
      : null;
  final pureEvent = onCalendar && !isTodo && !isRecord;
  if (pureEvent) return eventDay;
  if (isTodo && !isRecord && isRepeating && createdDay != null) {
    return createdDay;
  }
  if (dueDate != null && dueDate!.isNotEmpty) return dueDate;
  return createdDay;
}

occurrencesIn 은 이미 repeatAnchor 를 쓴다. 여기를 바꾸면 일반 달력과 반복 보기의 칸이 생성일로 따라간다. 반복 보기 버튼은 앱에 이미 있다. 지우지 않는다.

3. 할일 저장 — 빈 기한은 생성일

파일 lib/widgets/todo_edit_sheet.dart 의 _save. 생성일을 채운 직후, 할일이고 기한이 비어 있으면 생성일 날짜를 기한에 넣는다. 일정은 기한을 비운 채로 둔다. 일정 편집기에 기한 칸을 넣지 않는다.

final createdDay = _dateOnly(t.createdAt);
if (kind == 'todo' &&
    (t.dueDate == null || t.dueDate!.isEmpty) &&
    createdDay.isNotEmpty) {
  t.dueDate = createdDay;
}
if (kind == 'event') t.dueDate = null;

이 대입은 지금 있는 「기한은 생성일보다 빠를 수 없습니다」 검사보다 앞에 둔다. 기한이 생성일과 같으면 그 검사는 통과한다.

할일을 먼저 저장한 뒤 매주만 켜도 기한이 생성일과 같아서, 반복 조회에서 빠지지 않는다. 칸은 생성일이다.

4. 회차 이동 — 생성일과 기한을 함께

파일 lib/db/app_db.dart 의 _rollForwardImpl. 기준은 dueDate 가 아니라 생성일이다. 완료 회차는 그 생성일에 남기고, 다음 행은 생성일 + 주기, 기한은 새 생성일 + 간격이다. 미완료는 오늘 이하 최근 생성일 회차로 생성일과 기한을 함께 옮긴다.

조회 조건에서 due_date IS NOT NULL 을 빼도 된다. 생성일이 있는 반복 할일이 대상이다. 일정 반복은 지금처럼 가상 표시라 이 함수에 넣지 않는다.

String? _day10(String? v) {
  if (v == null || v.length < 10) return null;
  return v.substring(0, 10);
}

int _gapDays(String createdDay, String? due) {
  final d = _day10(due);
  if (d == null) return 0;
  final a = DateTime.tryParse(createdDay);
  final b = DateTime.tryParse(d);
  if (a == null || b == null) return 0;
  return DateTime(b.year, b.month, b.day)
      .difference(DateTime(a.year, a.month, a.day))
      .inDays;
}

String _plusDays(DateTime day, int gap) {
  final n = DateTime(day.year, day.month, day.day).add(Duration(days: gap));
  return _isoDate(n);
}

각 행의 처리:

  1. createdDay = _day10(t.createdAt) ?? t.dueDate. 없으면 건너뛴다.
  2. base = DateTime.parse(createdDay), gap = _gapDays(createdDay, t.dueDate).
  3. 완료면 done_date = createdDay, repeat_unit = null, cal_visible = 1. 다음 회차 next 가 있으면 새 행의 created_at = '${_isoDate(next)} 00:00:00', due_date = _plusDays(next, gap). 예전 생성일을 복사하지 않는다.
  4. 미완료면 latestOccurrenceOnOrBefore(base, ...). 결과가 base 와 같으면 그대로 둔다. 다르면 created_at 과 due_date 를 함께 갱신한다. 기한만 고치지 않는다.

그 날짜만 완료(completeRepeatOccurrence)도 회차 날짜를 생성일로 둔다. createdAt 은 원본을 복사하지 않고 '$iso 00:00:00', doneDate 는 iso, dueDate 는 그 날 + 간격이다.

5. 반복 조회

listRepeating 이 due_date IS NOT NULL 만 가져오면, 기한이 비어 있는 반복 할일과 기한이 없는 일정이 빠진다. 조건을 생성일 기준으로 바꾼다.

where:
  "user_id = ? AND repeat_unit IS NOT NULL AND repeat_unit != '' "
  "AND is_deleted = 0 AND ("
  "(NOT (on_calendar = 1 AND is_todo = 0 AND is_record = 0) "
  " AND (created_at IS NOT NULL OR due_date IS NOT NULL)) "
  "OR (on_calendar = 1 AND is_todo = 0 AND is_record = 0 "
  " AND created_at IS NOT NULL))"

달력에 숨김(calVisible = false)인 반복도 반복 보기에서는 회차를 모두 펼친다. 이 동작은 앱에 이미 있다. 유지한다.

6. 그대로 둘 것

웹이 보낸 동기화 행은 다음 회차의 created_at 과 due_date 가 함께 밀려 있다. 앱이 할일 반복을 기한에 두면, 같은 항목이 폰에서는 기한 칸에 보인다.

7. 확인

  1. 9월 24일을 생성일로 할일을 만들고 기한을 비운 채 저장한다. 기한이 9월 24일로 채워지고, 칸은 24일이다.
  2. 그 할일을 매주로 하고 기한을 9월 25일로 저장한다. 칸은 24일이다. 25일 칸에는 없다.
  3. 오늘이 10월 3일이면 생성일은 10월 1일, 기한은 10월 2일로 한 칸 옮겨 있다.
  4. 그 회차를 완료하면 10월 1일에 완료로 남고, 다음 행은 생성일 10월 8일, 기한 10월 9일이다.
  5. 일정은 기한 칸이 없고, 저장 후에도 dueDate 가 비어 있다. 칸은 생성일이다.
  6. 반복 보기에서는 그 달의 생성일 회차(24일, 10월 1일, …)가 달력 표시와 관계없이 보인다.