고쳤다고 적어 둔 버그가 그대로 있었다
저녁 6시에 시작해서 밤 12시에 끝나는 일정이 있다고 해 보겠습니다. 다음 날은 0분도 차지하지 않는데, DaySeed 캘린더는 이 일정을 다음 날 칸까지 이어진 막대로 그렸습니다. 다음 날 아젠다와 목록, 위젯에도 올라왔습니다.
이건 처음 보는 버그가 아니었습니다. 7월 29일에 종료 시각이 자정을 넘으면 종료 날짜를 다음 날로 붙이는 변경(fa789c1)을 넣었고, 같은 날 바로 이어서 "자정에 끝나는 일정은 종료일에 막대를 그리지 않는다"는 규칙(07294f4)을 넣었습니다. 그 커밋에는 bun test 115개 통과, 신규 9개라고 적혀 있습니다. 자정 트리밍, 여러 날 유지, 종일 유지, 반복 회차까지 테스트로 덮었다고 생각했습니다.
그런데 그 규칙은 실제 일정에는 한 번도 적용되지 않았습니다. 알게 된 건 석 달 가까이 지난 10월, 잠금화면 Live Activity 를 서버에서 띄우는 작업(live-activity-push-start)을 하면서였습니다. 앱의 반복 일정 전개 코드를 서버 엣지 함수로 옮기다가 이 줄에서 걸렸습니다. intent 에는 "2026-10-17 live-activity-push-start 작업 중 발견"으로 남아 있고, 10월 19일에 이어서 고쳤습니다.
문자열 하나가 달랐다
원인은 비교 한 줄이었습니다. 화면에서 마지막 날을 계산하는 visibleEndDayOf 는 종료 시각을 '00:00' 과 문자열로 비교했습니다.
export function visibleEndDayOf(event: EventSpan): string {
const end = event.endDay && event.endDay > event.day ? event.endDay : event.day;
if (end === event.day) return end;
if (event.startTime && event.endTime === '00:00') return addDaysYmd(end, -1);
return end;
}
일정을 만드는 폼은 시각을 HH:MM 으로 씁니다. 하지만 events.end_time 은 Postgres 의 time 컬럼이라, Supabase 의 REST 계층(PostgREST)을 거쳐 읽으면 '00:00:00' 으로 돌아옵니다. 화면이 그리는 일정은 전부 DB 에서 읽은 것이니, 이 조건은 참이 될 일이 없었습니다. 테스트가 통과한 이유도 같습니다. 픽스처가 폼 모양인 '00:00' 만 썼기 때문입니다.
캘린더 월 화면(스토어 스크린샷)입니다. 자정에 끝나는 일정이 다음 날 칸까지 막대로 이어지던 곳이 이 그리드입니다.
서버로 옮길 때의 흔적도 남아 있습니다. 서버 포트는 "앱과 결과를 맞춘다"는 제약 아래 같은 비교를 그대로 가져왔고, 그 위 주석에는 이미 이렇게 적혀 있었습니다.
/**
* recurrence.ts visibleEndDayOf — an event ending at 00:00 doesn't occupy its
* end day. Compares the raw value like the app does (PostgREST sends
* "00:00:00", so for DB rows this never fires on either side).
*/
양쪽 다 발동하지 않는다는 걸 알면서도, 그 단계에서는 결과를 똑같이 맞추는 쪽을 먼저 지킨 겁니다. 앱이 틀린 채로 서버만 고치면 잠금화면 카드와 캘린더가 서로 다른 날을 말하게 됩니다.
고친 내용은 작습니다. 자정인지 판정하는 함수를 따로 두고, 앱과 서버가 같은 정규식을 씁니다.
export function visibleEndDayOf(event: EventSpan): string {
const end = event.endDay && event.endDay > event.day ? event.endDay : event.day;
if (end === event.day) return end;
if (event.startTime && isMidnight(event.endTime)) return addDaysYmd(end, -1);
return end;
}
/**
* Rows from Supabase carry `time` columns as "HH:MM:SS" while forms write
* "HH:MM" — both spell midnight.
*/
export function isMidnight(time: string | null | undefined): boolean {
return !!time && /^00:00(:00(\.0+)?)?$/.test(time);
}
00:00, 00:00:00, 00:00:00.000 은 자정으로 보고, 00:00:30 처럼 초가 0이 아니면 자정이 아닙니다. spec 에는 앱이 초를 저장하지 않으니 실제로 나올 일은 없다고 적혀 있고, 이 경우도 테스트 케이스에 들어 있습니다.
왜 읽는 쪽에서 HH:MM 으로 자르지 않았나
더 근본적인 길은 DB 행을 앱의 Event 로 바꾸는 매퍼(rowToEvent)에서 시각을 아예 HH:MM 으로 잘라 버리는 것이었습니다. 그러면 비슷한 비교가 다른 곳에 숨어 있어도 한꺼번에 사라집니다. spec 은 이걸 비범위로 뺐습니다. 폼에 값을 채웠다가 다시 저장하는 왕복과 시각 비교 전반에 영향을 주니 별건으로 다룬다는 이유입니다. 버그 하나를 고치려다 모든 일정의 편집 경로를 건드리는 건 이번 수정의 크기에 맞지 않는다고 판단했습니다.
intent 의 제약도 같은 방향입니다. 화면에서 어디까지 보이는지만 바꾸고, 저장된 end_day·end_time 과 마이그레이션은 건드리지 않습니다. 히스토리의 "오전 12:00" 표기, 종일 일정, 18:00 에서 다음 날 10:00 까지 같은 진짜 여러 날 일정도 범위 밖입니다. 이건 7월 커밋 때 정한 것을 그대로 지킨 것입니다. 이번에 제품 결정은 없었습니다. 이미 정한 규칙을 실제 데이터에 닿게 한 수정입니다.
이번 일에서 가장 많이 남은 건 테스트 쪽 교훈입니다. 테스트가 데이터를 만들 때 폼이 쓰는 모양만 썼고, 앱이 실제로 받는 모양은 쓰지 않았습니다. 그래서 앱 테스트에도, 서버와 앱의 결과를 비교하는 패리티 테스트에도 DB 모양 픽스처를 더했습니다.
it('keeps a DB-shaped midnight ending (00:00:00) out of the day it only touches', () => {
const r = row({ id: 'm', day: '2026-10-08', end_day: '2026-10-09', start_time: '18:00:00', end_time: '00:00:00' });
expect(expandEventInRange(rowToEvent(r), '2026-10-09', '2026-10-09')).toEqual([]);
expect(occ.expandInRange(r, '2026-10-09', '2026-10-09', lunarYearlyToSolar)).toEqual([]);
});
같은 일정을 앱 쪽 전개와 서버 쪽 전개에 각각 넣고, 자정에만 닿는 다음 날에는 둘 다 아무것도 내놓지 않는지 봅니다. 패리티 픽스처 목록에도 '18:00:00'→'00:00:00' 행을 하나 더해서, 앞으로 한쪽만 바뀌면 비교 테스트가 잡게 했습니다.
확인한 것과 남은 것
커밋과 plan 에 남은 검증은 이렇습니다. bun test 552개 통과이고, 새로 넣은 것은 00:00:00·00:00:00.000·00:00:30·00:30:00 케이스와 서버·앱 양쪽에서 DB 모양 자정을 빼는지 보는 테스트입니다. tsc 는 기존 5건 외에 새 오류가 없고, eslint 와 deno check 는 깨끗하다고 적혀 있습니다.
plan 의 네 번째 항목은 아직 체크되어 있지 않습니다. 엣지 함수 재배포와 runtime 2.1.2·2.1.1 두 갈래의 OTA 는 바깥으로 나가는 작업이라 확인을 받은 뒤 하기로 했고, intent 목록에도 "OTA 2.1.2/2.1.1·함수 재배포 대기"로 남아 있습니다. 그러니 이 글을 쓰는 시점의 기록으로는, 고친 코드가 있고 테스트가 통과했다는 데까지입니다. 사용자 화면에 닿았다고 적을 근거는 아직 없습니다.
인수 시나리오는 plan 에 세 줄로 적어 두었습니다. 18:00→다음 날 00:00 일정은 시작일 하루 막대만 그려지고 다음 날 아젠다·목록·위젯에 나오지 않을 것. 18:00→다음 날 10:00 은 이틀 막대 그대로일 것. 서버 플랜에서 같은 일정이 다음 날 Live Activity 후보가 되지 않을 것. 배포 뒤에 이 세 줄을 실제 화면에서 보는 것까지가 남은 일입니다.
DaySeed 는 매일의 습관을 한 번의 탭으로 기록하고 캘린더를 가장 가까운 사람과 함께 쓰는 iOS 앱입니다. App Store 에서 받기