nextcal
일정을 늘 보이는 자리에. VS Code 상태 표시줄과 바탕화면 달력.
소스 비공개 · 마켓 미발행.
.vsix로 묶어 로컬에 설치해 쓰는 개인 도구다.
nextcal은?
일정을 늘 눈에 보이는 자리에 두는 개인 일정 도구다. 클라이언트가 둘이다. VS Code 상태 표시줄과 사이드바에 다음 일정을 띄우는 확장, 그리고 Windows 바탕화면에 깔리는 월 격자 달력(Electron). 둘은 서버 없이 같은 로컬 일정 파일 하나를 읽고 쓴다.
구글 캘린더 연동은 있지만 기본으로 꺼져 있다. 일정의 원본은 내 컴퓨터에 있다.
왜 만들었나
출발은 "일정을 항상 띄워 두고 싶다"였다. 처음에는 바탕화면 위젯(Rainmeter, Rainlendar, Lively Wallpaper)을 떠올렸는데, 하루 종일 열어 두는 창이 VS Code 라서 상태 표시줄이 가장 잘 보이는 자리라고 봤다. 그래서 첫 판은 구글 캘린더의 비공개 ICS 주소를 읽어 상태 표시줄에 다음 일정을 띄우는 VS Code 확장이었다.
그 다음에 두 가지를 바꿨다. 하나는 저장이다. 개발하면서 생기는 일정은 대부분 날짜 없는 할 일인데, 구글 캘린더는 이런 항목을 담는 그릇이 아니다. 쓰기 권한을 받는 절차도 필요 없어서 저장을 로컬 파일로 옮겼다. 다른 하나는 처음에 미뤄 둔 바탕화면이다. 따로 백엔드를 두지 않고, 같은 일정 파일을 읽는 두 번째 클라이언트로 월 격자 달력을 붙였다.
전체 구조
저장소는 패키지 셋으로 나뉜다. store 가 공용이고, 확장과 바탕화면 앱은 이것을 통해서만 데이터에 닿는다. 바탕화면 앱에서 schedule.json 을 바로 여는 코드는 실패로 처리해 두었다. 기능이 모자라면 앱 쪽에 새로 짜지 않고 store 를 넓힌다.
일정 파일을 지키는 법
본 파일은 아무도 바로 고치지 않는다
일정은 AI 대화나 다른 창에서도 들어온다. 여러 쪽이 schedule.json 을 동시에 고치면 깨진다. 그래서 추가는 받은 편지함 폴더에 항목 파일을 떨어뜨리는 것으로 끝나고, 본 파일에 합치는 일은 store 가 알아서 한다. 형식 검사에 걸린 파일은 지우지 않고 invalid 폴더로 옮긴다. 일정이 조용히 사라지는 것이 가장 나쁘다고 봤다.
잠금 없이는 절반 가까이 사라졌다
리뷰에서 막힌 것이 있었다. 두 창이 받은 편지함을 동시에 처리하면 한쪽이 오래된 목록으로 덮어써 일정이 사라지는데, 로그에는 성공으로 남았다. 프로세스 셋으로 75건을 동시에 넣어 보니 잠금 없이는 36건이 사라졌고, 파일 잠금을 건 뒤에는 75건이 다 남았다.
store 를 공용 패키지로 뺀 이유도 여기에 있다. 바탕화면 앱이 생기면서 쓰는 쪽이 둘이 됐는데, 두 앱이 잠금을 각자 구현하면 데이터가 깨진다. 공용 판정 기준은 "vscode 를 모르는 코드만"으로 잡았고, tsconfig 만으로는 store 의 vscode import 를 못 막아서 린트 규칙으로 막았다.
지어내지 않는다
끝 시각이 없는 일정은 시작하자마자 "지난 일정"으로 넘어갔다. 회의는 시작 직후에 가장 많이 들여다보는데 그때 사라지면 쓸 수 없다. 그렇다고 저장 데이터에 끝 시각을 임의로 넣지는 않았다. 화면에서만 15분 유예를 둔다. 같은 이유로 Date.parse 가 2월 30일 같은 없는 날짜를 알아서 넘겨 버리는 것도 월별 일수 검사로 막았다. 공휴일 표도 날짜가 확정되지 않은 해는 비워 둔다. 틀린 날짜를 보여 주는 것보다 낫다.
두 화면이 어긋났다
바탕화면 앱을 붙이자 같은 데이터를 두 화면이 다르게 보여 주는 곳이 나왔다.
- 사이드바에서 지운 항목을 바탕화면에서 고치면 성공으로 보이지만 저장되지 않았다. 이제는 "저장소에 없음" 오류를 띄운다.
- 자정에 시작하지 않는 종일 항목이 바탕화면에서는 하루, 사이드바에서는 이틀이었다. 날수 계산을
store의 함수 하나로 모으고, 같은 입력을 두 쪽에 넣는 교차 시험을 붙였다.
둘 다 "각자 계산하면 언젠가 갈린다"는 같은 문제였다. 공용 패키지가 잠금만이 아니라 계산의 정본이어야 했다.
바탕화면에 붙어 있기
바탕화면 달력에서 가장 오래 걸린 것은 Windows 의 Win+D(바탕화면 보기)였다.
처음에는 창의 소유자를 바탕화면 셸로 지정해 Win+D 뒤에도 달력이 남게 했다. 그러자 Win+D 를 두 번 누르면 숨겼던 다른 창들이 돌아오지 않았다. 달력을 되살리는 보조 처리를 의심해 뺐고, 다음에는 진단 로그에 찍힌 입력기의 숨은 창과 2초마다 도는 재배치를 의심했다. 셋을 끄고 켜는 판으로 나눠 다시 재 보니 원인은 셸에 붙이는 처리 자체였다. 그것을 기본에서 빼자 토글은 돌아왔지만 이번엔 달력도 같이 숨었다.
답은 Rainmeter 의 바탕화면 고정 구현을 읽고 얻었다. GPL 이라 코드는 가져오지 않고 원리만 옮겨 새로 짰다.
로그로 "달력 올림"과 "다른 창 복귀"가 짝을 이루는 것을 확인한 뒤 이 방식을 기본 모드로 바꿨다. 대가는 하나 있다. 도구 창이 되면서 Alt+Tab 목록에서 빠져, 꺼낼 때는 트레이 아이콘을 쓴다.
검증 방식에도 규칙을 하나 뒀다. Win+D 를 에이전트가 눌러서 확인하면 사용하는 창이 전부 숨겨진다. 그래서 키를 누르는 대신 Win32 API 로 창 상태만 읽었다. 진단 로그는 처음에 다른 앱의 창 제목까지 남기고 있어서, 창 종류와 프로세스 이름만 남기게 고치고 옛 로그는 지웠다.
입력은 잃지 않는 쪽으로
달력 칸을 누르면 그 칸의 다음 번호 줄에 바로 커서가 선다. 상자나 테두리 없이 기존 항목과 같은 글꼴, 같은 줄 간격이다. 이렇게 입력이 눈에 덜 띄면 실패도 눈에 덜 띈다. 그래서 정책을 "입력이 사라지는 것이 가장 치명적"에 맞췄다.
- 다른 앱으로 넘어가도 쓰던 글자는 남는다
- 달력 안 다른 곳을 누르면 글자가 있을 때만 저장한다
- 취소는 Esc 로만 된다
- 저장에 실패해도 글자는 그 줄에 남는다
투명도는 창 전체가 아니라 배경만 낮춘다. 글자까지 흐려지면 바탕화면에 깔아 둘 이유가 없다.
Decision Log
처음에는 구글 안내를 숨기는 단추를 달았다. 확인해 보니 뜻이 "지금 안 씀"이 아니라 "아예 안 씀"이었다. 그래서 연동 자체를 기본 꺼짐으로 바꿨다. 주소가 없으면 안내도, 네트워크 요청도, 갱신 타이머도 돌지 않는다. 코드는 지우지 않고 명령 팔레트로만 켠다.
날짜 없는 할 일을 다루려면 자체 형식이 맞다. 외부 캘린더 읽기는 비용이 거의 없어 남겨 두고, 쓰기 권한(OAuth)은 비용이 커서 미뤘다.
자체 백엔드나 Firebase 에 얹는 안을 접었다. 개인 일정 도구가 배포 상태에 묶이고, 오프라인이면 달력이 안 뜬다. PC 한 대에서는 로컬 파일과 잠금으로 충분하다. 폰에서 구독하는 ICS 발행도 지금 폰으로 달력을 보지 않아서 미뤘다.
리뷰는 느린 반복 전개를 줄이려고 DTSTART 를 조회 범위로 당기자고 했다. 받지 않았다. INTERVAL 과 BYDAY 가 DTSTART 에 묶여 있어서 옮기면 생성되는 회차가 달라진다. 대신 파싱을 비동기로 돌려 확장 호스트가 막히지 않게 했다. 반복과 표준시는 손으로 짜면 틀리기 쉬워 ical.js 에 맡겼다.
창을 완전히 비활성으로 두면 늘 아래에 깔리지만 키보드 입력을 못 받는다. 평소에는 가라앉고, 누르면 올라오고, 포커스를 잃으면 다시 가라앉게 했다.
기술 스택
- 언어: TypeScript (tsc · eslint · esbuild)
- VS Code 확장: VS Code Extension API · SecretStorage(비공개 ICS 주소) · ical.js · vsce
- 바탕화면 앱: Electron · Win32 API(창 순서와 상태 읽기)
- 저장: 홈 디렉터리의 JSON 파일 + 받은 편지함 폴더 + 파일 잠금. 서버 없음
지금 상태와 남은 것
확장은 .vsix 로 묶어 로컬에 설치했고, 바탕화면 달력은 바로가기와 트레이로 띄운다. 마켓에는 올리지 않았고 소스는 비공개다.