WIKInextcal
개인 제품·2026.09 ~ (로컬 설치, 미발행)·기획·개발 단독

nextcal

일정을 늘 보이는 자리에. VS Code 상태 표시줄과 바탕화면 달력.

TypeScriptVS Code Extension APIElectronical.jsWin32 APIesbuild

소스 비공개 · 마켓 미발행. .vsix 로 묶어 로컬에 설치해 쓰는 개인 도구다.

nextcal은?

일정을 늘 눈에 보이는 자리에 두는 개인 일정 도구다. 클라이언트가 둘이다. VS Code 상태 표시줄과 사이드바에 다음 일정을 띄우는 확장, 그리고 Windows 바탕화면에 깔리는 월 격자 달력(Electron). 둘은 서버 없이 같은 로컬 일정 파일 하나를 읽고 쓴다.

구글 캘린더 연동은 있지만 기본으로 꺼져 있다. 일정의 원본은 내 컴퓨터에 있다.

왜 만들었나

출발은 "일정을 항상 띄워 두고 싶다"였다. 처음에는 바탕화면 위젯(Rainmeter, Rainlendar, Lively Wallpaper)을 떠올렸는데, 하루 종일 열어 두는 창이 VS Code 라서 상태 표시줄이 가장 잘 보이는 자리라고 봤다. 그래서 첫 판은 구글 캘린더의 비공개 ICS 주소를 읽어 상태 표시줄에 다음 일정을 띄우는 VS Code 확장이었다.

그 다음에 두 가지를 바꿨다. 하나는 저장이다. 개발하면서 생기는 일정은 대부분 날짜 없는 할 일인데, 구글 캘린더는 이런 항목을 담는 그릇이 아니다. 쓰기 권한을 받는 절차도 필요 없어서 저장을 로컬 파일로 옮겼다. 다른 하나는 처음에 미뤄 둔 바탕화면이다. 따로 백엔드를 두지 않고, 같은 일정 파일을 읽는 두 번째 클라이언트로 월 격자 달력을 붙였다.

전체 구조

받은 편지함
일정 추가는 항목 파일 하나를 떨어뜨리는 것으로 끝난다
packages/store
잠금 · 검증 · 받은 편지함 처리. vscode 를 모르는 코드만
schedule.json
홈 디렉터리 한 곳. 일정의 원본
VS Code 확장
상태 표시줄 다음 일정 + 사이드바 목록
바탕화면 달력
Electron 월 격자. 일요일 시작 6주

저장소는 패키지 셋으로 나뉜다. store 가 공용이고, 확장과 바탕화면 앱은 이것을 통해서만 데이터에 닿는다. 바탕화면 앱에서 schedule.json 을 바로 여는 코드는 실패로 처리해 두었다. 기능이 모자라면 앱 쪽에 새로 짜지 않고 store 를 넓힌다.

2
클라이언트 (확장 · 바탕화면)
1
일정 파일
0
서버
75/75
동시 투입 시험, 잠금 후 보존
상태 표시줄에 다음 일정, 사이드바에 목록
바탕화면 월 격자 달력, 공휴일 표시
받은 편지함 방식 추가와 파일 잠금
제목만 있으면 되는 날짜 없는 할 일
구글 캘린더 읽기는 있지만 기본 꺼짐
Win+D 를 눌러도 남는 바탕화면 달력

일정 파일을 지키는 법

본 파일은 아무도 바로 고치지 않는다

일정은 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 이라 코드는 가져오지 않고 원리만 옮겨 새로 짰다.

어디에도 붙이지 않는다
셸에 소유자로 붙이던 방식을 버린다
바탕화면 보기를 감지한다
포커스 이벤트. 24H2 이후 판은 포커스가 Progman 창으로 넘어가는 것을 기준으로
그동안만 맨 위로
창을 되살리거나 포커스를 가져오면 Win+D 상태가 깨진다. 순서만 바꾼다
최소화되지 않게
최소화 불가 · 도구 창 속성

로그로 "달력 올림"과 "다른 창 복귀"가 짝을 이루는 것을 확인한 뒤 이 방식을 기본 모드로 바꿨다. 대가는 하나 있다. 도구 창이 되면서 Alt+Tab 목록에서 빠져, 꺼낼 때는 트레이 아이콘을 쓴다.

검증 방식에도 규칙을 하나 뒀다. Win+D 를 에이전트가 눌러서 확인하면 사용하는 창이 전부 숨겨진다. 그래서 키를 누르는 대신 Win32 API 로 창 상태만 읽었다. 진단 로그는 처음에 다른 앱의 창 제목까지 남기고 있어서, 창 종류와 프로세스 이름만 남기게 고치고 옛 로그는 지웠다.

입력은 잃지 않는 쪽으로

달력 칸을 누르면 그 칸의 다음 번호 줄에 바로 커서가 선다. 상자나 테두리 없이 기존 항목과 같은 글꼴, 같은 줄 간격이다. 이렇게 입력이 눈에 덜 띄면 실패도 눈에 덜 띈다. 그래서 정책을 "입력이 사라지는 것이 가장 치명적"에 맞췄다.

  • 다른 앱으로 넘어가도 쓰던 글자는 남는다
  • 달력 안 다른 곳을 누르면 글자가 있을 때만 저장한다
  • 취소는 Esc 로만 된다
  • 저장에 실패해도 글자는 그 줄에 남는다

투명도는 창 전체가 아니라 배경만 낮춘다. 글자까지 흐려지면 바탕화면에 깔아 둘 이유가 없다.

Decision Log

Decision #01구글 연동은 숨기지 않고 끈다

처음에는 구글 안내를 숨기는 단추를 달았다. 확인해 보니 뜻이 "지금 안 씀"이 아니라 "아예 안 씀"이었다. 그래서 연동 자체를 기본 꺼짐으로 바꿨다. 주소가 없으면 안내도, 네트워크 요청도, 갱신 타이머도 돌지 않는다. 코드는 지우지 않고 명령 팔레트로만 켠다.

Decision #02원본은 로컬, 밖에 보일 것만 내보낸다

날짜 없는 할 일을 다루려면 자체 형식이 맞다. 외부 캘린더 읽기는 비용이 거의 없어 남겨 두고, 쓰기 권한(OAuth)은 비용이 커서 미뤘다.

Decision #03서버를 들이지 않는다

자체 백엔드나 Firebase 에 얹는 안을 접었다. 개인 일정 도구가 배포 상태에 묶이고, 오프라인이면 달력이 안 뜬다. PC 한 대에서는 로컬 파일과 잠금으로 충분하다. 폰에서 구독하는 ICS 발행도 지금 폰으로 달력을 보지 않아서 미뤘다.

Decision #04느려도 정확한 반복 일정

리뷰는 느린 반복 전개를 줄이려고 DTSTART 를 조회 범위로 당기자고 했다. 받지 않았다. INTERVAL 과 BYDAY 가 DTSTART 에 묶여 있어서 옮기면 생성되는 회차가 달라진다. 대신 파싱을 비동기로 돌려 확장 호스트가 막히지 않게 했다. 반복과 표준시는 손으로 짜면 틀리기 쉬워 ical.js 에 맡겼다.

Decision #05창은 가라앉되 입력은 받는다

창을 완전히 비활성으로 두면 늘 아래에 깔리지만 키보드 입력을 못 받는다. 평소에는 가라앉고, 누르면 올라오고, 포커스를 잃으면 다시 가라앉게 했다.

기술 스택

  • 언어: TypeScript (tsc · eslint · esbuild)
  • VS Code 확장: VS Code Extension API · SecretStorage(비공개 ICS 주소) · ical.js · vsce
  • 바탕화면 앱: Electron · Win32 API(창 순서와 상태 읽기)
  • 저장: 홈 디렉터리의 JSON 파일 + 받은 편지함 폴더 + 파일 잠금. 서버 없음

지금 상태와 남은 것

확장은 .vsix 로 묶어 로컬에 설치했고, 바탕화면 달력은 바로가기와 트레이로 띄운다. 마켓에는 올리지 않았고 소스는 비공개다.

사이드 패널을 "오늘 일정" 중심으로 다시 짜는 중이다. 오늘만 펼치고 내일, 지난 미완료, 언젠가는 한 줄로 접는다. 지난 미완료는 숨기지 않고 접은 줄로 남겨 빠뜨리지 않게 한다. 다크와 라이트, 펼친 상태, 일정 없는 날 화면을 배치도와 맞춰 보는 일이 남았다. 한글 입력기로 여러 줄을 이어 적을 때 마지막 글자가 빠진다는 현상은 아직 재현하지 못했다. 그 뒤 순서는 주간 뷰, 임박 알림 하나, 대화에서 일정 후보를 모으는 훅이다. 후보는 받되 시각을 지어내지 않고, 날짜가 불분명하면 "언젠가"로 보낸다. 2028년 이후 공휴일은 날짜가 확정되면 채운다.