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일, …)가 달력 표시와 관계없이 보인다.