서버 · 웹 동기화 — 날짜 규칙

2026-09-23. 앱(auto_schedule_app / day_plan_app)에 할일·일정·기록 날짜 규칙을 넣었다. 웹 modam_doc/app_schedule 과 동기화 병합이 같은 의미를 써야 폰과 웹이 같은 칸에 같은 항목을 보여 준다.

1. 왜 웹을 고쳐야 하는가

HTTP 경로와 JSON 봉투는 그대로다. 컬럼을 새로 만든 것도 아니다. 바뀐 것은 같은 컬럼의 의미다.

지금 웹·서버지금 앱
할일 달력 칸 due_date created_at 앞 10자
일정 달력 칸 due_date created_at 앞 10자. 기한 없음
기록 달력 칸 due_date due_date (그대로)
오늘 목록 기한 ≤ 오늘 + 나의하루 + 별표 미래 기한 ≤ 오늘 + 오늘 할일 + 생성일=오늘. 별표만으로는 안 넣음
동기화 created_at DB에는 있으나 병합·스냅샷에 없음 값이 있으면 보낸다. 스냅샷에 없으면 로컬 값을 유지
이 상태로 앱이 일정을 저장하면 due_date = null 이 서버에 올라간다. 웹 달력은 아직 기한으로 일정을 찾으므로 웹에서 일정이 사라진다. 반대로 웹이 기한 칸에 둔 일정을 앱은 생성일 칸에 두므로 같은 항목이 다른 날에 보인다.

앱 규칙 원문: auto_schedule_app/docs/2026-09-23/할일일정기록_날짜규칙.html (같은 파일이 day_plan_app/docs/2026-09-23/ 에도 있다).

2. 손대지 않는 것

3. 맞출 날짜 의미

한 레코드가 할일, 일정, 기록 중 하나다. 비교는 yyyy-MM-dd, 오늘은 서버 로컬 날짜(timezone.localdate()).

필드할일일정기록
created_at 달력·목록에 놓는 날. 고른 칸. 일정 그 자체인 날. 기한을 대신한다. 만든 시각. 칸에 쓰지 않는다.
due_date 마감. 비어 있을 수 있다. 칸 위치가 아니다. 없음. 저장·동기화 때 비운다. 기록한 날. 칸 위치다.
done_date 완료로 바꾼 날. 쓰지 않는다. 지난 날은 날짜 비교로 완료다. 해당 없음.
생성일이 기한보다 늦으면 안 된다. 생성 2026-09-23, 기한 2026-09-22 금지. 기한이 있으면 생성일 ≤ 기한.

할일

일정

오늘 목록 (할일만)

  1. 기한 ≤ 오늘 (지연이거나 기한이 오늘)
  2. “오늘 할일”(is_my_day)이 켜져 있다
  3. 반복이 아니고 생성일 날짜가 오늘이다

별표는 오늘에 넣는 이유가 아니다. 지금 웹 _today_where()의 is_star = 1 AND due_date >= today 를 뺀다.

정렬 · 칸

할일 탭·일정 탭 묶음 순서(오름/내림과 무관): 오늘 → 예정 → 지연 → 완료. 일정에는 지연 묶음이 없다. 한 묶음 안에서만 생성일 → 기한 → 완료일.

달력 칸 줄: 날짜 → 공휴일 → 기록 → 일정 → 할일 → 구글 점.

4. 반드시 이 순서로 배포

스냅샷에 created_at 을 먼저 넣으면, 서버 기본값(넣은 시각)이 폰의 생성일을 덮어 쓴다. 일정 날이 오늘로 바뀐다. 아래 1 → 2 → 3 → 4 를 지키면 된다.
  1. 병합 쓰기만 created_at 을 받는다. _content_vals / _CONTENT_ORDER / INSERT·UPDATE. 스냅샷(_SYNC_COLS)은 아직 열지 않는다.
  2. 앱이 한 번 이상 동기화한다. 폰의 생성일이 서버에 저장된다. (이미 기한이 지워진 일정도 생성일이 서버에 남는다.)
  3. 그다음 스냅샷에 created_at 을 넣고 fetch_all_sync 에서 _stamp() 한다.
  4. 서버에서 일정 기한 마이그레이션을 돌리고, 조회 SQL·화면을 날짜 규칙에 맞춘다.

1만 배포한 동안 구버전 앱은 created_at 을 안 보내도 된다. 서버는 기존처럼 DEFAULT 로 채운다. 하위 호환이다.

5. 동기화 필드 — sql_statement.py

파일: app_schedule/pkg_sql_statement/sql_statement.py

지금 (스냅샷에 created_at 없음)

_SYNC_COLS = (
    "sync_uid, updated_at, is_deleted, content, is_done, is_star, is_my_day, "
    "due_date, remind_time, remind_day, event_time, repeat_unit, repeat_interval, repeat_until, note, "
    "on_calendar, cal_visible, is_record, is_todo, last_done_date, done_date"
)

_CONTENT_ORDER = (
    'content', 'is_done', 'is_star', 'is_my_day', 'due_date', 'remind_time',
    'remind_day', 'event_time', 'repeat_unit', 'repeat_interval', 'repeat_until', 'note',
    'on_calendar', 'cal_visible', 'is_record', 'is_todo', 'last_done_date',
    'done_date', 'my_day_date', 'is_deleted',
)

바꿀 점

  1. _content_vals 에 'created_at': _stamp(t.get('created_at')) or None. 빈 값이면 None → INSERT 때 DB DEFAULT.
  2. _CONTENT_ORDER 끝에 'created_at'.
  3. 단계 3 이후 _SYNC_COLS 끝에 created_at.
  4. fetch_all_sync 에서 updated_at 처럼 r['created_at'] = _stamp(r.get('created_at')).
  5. 강제 Push(api_todos POST)는 이미 _CONTENT_ORDER 를 쓰므로 같이 따라온다.

앱이 보내는 형식: 'yyyy-MM-dd HH:mm:ss' (로컬). 예: 2026-09-25 00:00:00. MariaDB DATETIME 에 그대로 넣는다. JSON 으로 내릴 때도 같은 문자열.

my_day_date 는 서버 전용 파생값이다. 앱은 안 보낸다. 지금처럼 is_my_day 이면 기한(없으면 생성일 날짜)으로 채워도 된다. “오늘 할일”이 생성일을 오늘로 옮기면 기한이 비어도 생성일=오늘이라 오늘 목록에 들어간다.

6. 기존 일정 마이그레이션

앱 AppDb.dropEventDueDates() 와 같다. 순수 일정만 (on_calendar=1 AND is_todo=0 AND is_record=0). 할일·기록의 기한은 지우지 않는다.

-- 1) 생성일이 없거나 기한보다 늦으면 생성일을 기한 날로 당긴다.
UPDATE tb_todo
   SET created_at = CONCAT(due_date, ' 00:00:00')
 WHERE on_calendar = 1 AND is_todo = 0 AND is_record = 0 AND is_deleted = 0
   AND due_date IS NOT NULL AND due_date != ''
   AND (created_at IS NULL
        OR DATE(created_at) > due_date);

-- 2) 일정의 기한을 모두 지운다. 칸은 생성일이다.
UPDATE tb_todo
   SET due_date = NULL
 WHERE on_calendar = 1 AND is_todo = 0 AND is_record = 0 AND is_deleted = 0
   AND due_date IS NOT NULL;

넣을 곳: init_schema() 끝, 또는 배포 스크립트 1회.

7. 조회 SQL 을 칸 의미에 맞추기

헬퍼를 쓰면 반복이 줄어든다.

-- 생성일 날짜 (DATETIME / 문자열 모두)
# DATE(created_at)  또는  LEFT(created_at, 10)

-- 할일 비반복 칸: 생성일, 없으면 기한
# COALESCE(NULLIF(DATE(created_at), '0000-00-00'), due_date)
# 앱: substr(created_at,1,10) 없으면 due_date

일정 — 기한 대신 생성일

함수지금이후
list_events_only due_date = %s DATE(created_at) = %s
cal_events_in_range due_date 구간 DATE(created_at) 구간. 반환 키도 생성일 날짜. 뷰가 due_date 로 칸을 묶으면 생성일 별칭을 쓰거나 뷰를 고친다.
events_in_range_full due_date 구간 생성일 구간. _month_events 의 e['date'] 도 생성일.
list_repeating due_date IS NOT NULL 만 할일: 기한 있음. 일정: 생성일 있음. 반복 전개 기준일은 일정=DATE(created_at), 할일=due_date.
reminders_for due_date IS NOT NULL 할일 기준일=기한. 일정 기준일=생성일. (due_date IS NOT NULL AND due_date != '') OR (on_calendar=1 AND is_todo=0 AND created_at IS NOT NULL)

할일 — 칸은 생성일, 오늘 목록은 규칙 5

함수지금이후
list_todos due_date = %s 비반복: 생성일(없으면 기한)=그 날. 반복: due_date = 그 날
cal_todos_in_range due_date 구간 비반복 생성일 구간(없으면 기한). 반복은 뷰에서 전개
_today_where / todos_by_view('today') 기한≤오늘 OR 나의하루 OR 별표 미래 기한≤오늘 OR is_my_day=1 OR (비반복 AND DATE(created_at)=오늘). 별표 조건 삭제
오늘 배지 카운트 위와 같은 별표 포함 오늘 WHERE 와 동일

기록 조회(cal_records_in_range, recordsOnDate 해당)는 due_date 유지.

뷰에서 손볼 곳

파일: app_schedule/pkg_views/views_schedule.py

8. 웹 화면

파일: app_schedule/templates/app_schedule/

9. 이식 체크

동기화

규칙

10. 작업 파일 목록

파일할 일
app_schedule/pkg_sql_statement/sql_statement.py 병합 필드, 스냅샷 stamp, 마이그레이션, 조회 SQL, 오늘 WHERE, 반복/알림
app_schedule/pkg_views/views_schedule.py 달력 묶기, 이달 일정 날짜, 정렬, 저장 시 일정 기한 비우기·생성일 저장
app_schedule/templates/app_schedule/_todo_fields.html 일정 기한 hidden 제거, 할일 생성일, 오늘 할일 이동
app_schedule/templates/app_schedule/schedule.html 칸 순서, 일정 타일 날짜 줄, 제목 옆 오늘 날짜, 별표 칩
app_schedule/templates/app_schedule/_todo_item.html 기한 라벨, 별은 제목 뒤
반복 모듈(뷰에서 due_date 를 시작일로 쓰는 곳) 일정은 생성일 앵커

한줄