Scriptable을 이용해 습관 트래커 만들기

앱 스토어에 여러 습관 추적 앱이 있지만 유료 결제를 하지 않으면 위젯 기능을 사용하지 못하는 등의 제약이 있다. 그동안 HabitKit이라는 앱을 월 3,300원을 주고 사용하였으나 매달 빠져 나가는 돈이 은근 아까웠고, 때마침 자주 보는 jvscholz의 영상을 통해 Scriptable로 직접 만들 수 있다는 것을 알게 되었다.

이 방법을 사용하면 유료 앱 없이도 iOS 위젯 상에서 습관을 기록하고 확인할 수 있다. JSON을 활용해 데이터를 직접 관리하고, Scriptable을 통해 커스터마이징한 디자인까지 적용할 수 있어 유료 앱 못지않은 트래커를 만들 수 있다는 장점이 있다. 이제부터 그 방법을 차례로 설명하겠다.

🙋‍♂️ 이 방법은 애플의 ‘단축어’와 ‘위젯’ 기능에 어느 정도 익숙해야 어렵지 않게 적용할 수 있다.

1. Scriptable 다운로드

Scriptable은 iOS에서 JavaScript로 자동화 및 위젯을 만들 수 있는 앱이다. 그렇다. 이 방법은 iOS에서만 가능하다. 우선 Scripable 어플을 핸드폰에 다운로드 받자.

우리는 앞으로 Scriptable에 하나의 습관 당 3개의 스크립트를 생성할 것이다.

  1. 해빗 트래커를 시각적으로 보여주는 위젯 스크립트
  2. json 파일을 생성하는 스크립트 (기록이 쌓이는 곳)
  3. json 파일에 기록을 추가하는 스크립트

Scriptable에서 Script를 생성하는 방법은 다음과 같다.

  1. 우측 상단의 + 버튼을 클릭한다.

  2. 순서대로 클릭하여 이름을 변경한다.
    • 필수는 아니지만, 미리미리 이름을 변경해둬야 나중에 헷갈리지 않는다.
    • 예) 달리기 기록인 경우 : Running Tracker, Running json, Running Add와 같이 이름을 변경

2. Tracker 데이터 파일 생성하기

아래 코드를 각 Script에 추가한다.

2-1. 해빗 트래커를 시각적으로 보여주는 위젯 스크립트


const EVENT_NAME = "Running Tracker"; // 이름을 변경
const GOAL_DAYS = 260;
const FILE_NAME = "running-log.json"; // json 파일 이름 변경

const BG_IMAGE_URL = ""; 
const BG_COLOR = "#5C3B2E"; 
const BG_OVERLAY_OPACITY = 0.5;
const COLOR_FILLED = new Color("#FFB347"); 
const COLOR_UNFILLED = new Color("#FFB347", 0.3);

// Layout
const PADDING = 8;
const CIRCLE_SIZE = 6;
const CIRCLE_SPACING = 4;
const TEXT_SPACING = 8;
const DOT_SHIFT_LEFT = 2;
const YEAR_OFFSET = DOT_SHIFT_LEFT - 2;
const DAYS_LEFT_OFFSET = 0;

// Fonts
const MENLO_REGULAR = new Font("Menlo", 12);
const MENLO_BOLD = new Font("Menlo-Bold", 12);

// === Helper: 현지 날짜 문자열로 변환 ===
function getLocalDateString(date) {
  const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
  return local.toISOString().slice(0, 10);
}

// === Load data ===
const fm = FileManager.iCloud();
const path = fm.joinPath(fm.documentsDirectory(), FILE_NAME);
await fm.downloadFileFromiCloud(path);

let runDates = [];
if (fm.fileExists(path)) {
  const raw = fm.readString(path);
  runDates = JSON.parse(raw);
}
runDates = [...new Set(runDates)];

const today = new Date();

const timelineDates = [];
for (let i = GOAL_DAYS - 1; i >= 0; i--) {
  const d = new Date(today);
  d.setDate(d.getDate() - i);
  const dateStr = getLocalDateString(d);
  timelineDates.push(dateStr);
}

const daysCompleted = timelineDates.filter((date) =>
  runDates.includes(date)
).length;

// === Widget ===
const widget = new ListWidget();

// Background
if (BG_IMAGE_URL) {
  try {
    const req = new Request(BG_IMAGE_URL);
    const bgImage = await req.loadImage();
    widget.backgroundImage = bgImage;
  } catch (e) {
    console.log("Couldn't load background image");
  }
}

const overlay = new LinearGradient();
overlay.locations = [0, 1];
overlay.colors = [
  new Color(BG_COLOR, BG_OVERLAY_OPACITY),
  new Color(BG_COLOR, BG_OVERLAY_OPACITY),
];
widget.backgroundGradient = overlay;

// Layout calc
const WIDGET_WIDTH = 320;
const AVAILABLE_WIDTH = WIDGET_WIDTH - 2 * PADDING;
const TOTAL_CIRCLE_WIDTH = CIRCLE_SIZE + CIRCLE_SPACING;
const COLUMNS = Math.floor(AVAILABLE_WIDTH / TOTAL_CIRCLE_WIDTH);
const ROWS = Math.ceil(GOAL_DAYS / COLUMNS);

widget.setPadding(12, PADDING, 12, PADDING);

// Grid
const gridContainer = widget.addStack();
gridContainer.layoutVertically();

const gridStack = gridContainer.addStack();
gridStack.layoutVertically();
gridStack.spacing = CIRCLE_SPACING;

for (let row = 0; row < ROWS; row++) {
  const rowStack = gridStack.addStack();
  rowStack.layoutHorizontally();
  rowStack.addSpacer(DOT_SHIFT_LEFT);

  for (let col = 0; col < COLUMNS; col++) {
    const dotIndex = row * COLUMNS + col;
    if (dotIndex >= GOAL_DAYS) continue;

    const circle = rowStack.addText("");
    circle.font = Font.systemFont(CIRCLE_SIZE);

    const date = timelineDates[dotIndex];
    const filled = runDates.includes(date);
    circle.textColor = filled ? COLOR_FILLED : COLOR_UNFILLED;

    if (col < COLUMNS - 1) rowStack.addSpacer(CIRCLE_SPACING);
  }
}

widget.addSpacer(TEXT_SPACING);

// Footer
const footer = widget.addStack();
footer.layoutHorizontally();

const eventStack = footer.addStack();
eventStack.addSpacer(YEAR_OFFSET);
const eventText = eventStack.addText(EVENT_NAME);
eventText.font = MENLO_BOLD;
eventText.textColor = COLOR_FILLED;

const daysText = `${daysCompleted}/${GOAL_DAYS} days`;
const textWidth = daysText.length * 7.5;
const availableSpace =
  WIDGET_WIDTH - PADDING * 2 - YEAR_OFFSET - eventText.text.length * 7.5;
const spacerLength = availableSpace - textWidth + DAYS_LEFT_OFFSET;

footer.addSpacer(spacerLength);

const daysTextStack = footer.addStack();
const daysLeft = daysTextStack.addText(daysText);
daysLeft.font = MENLO_REGULAR;
daysLeft.textColor = COLOR_UNFILLED;

// Show
if (config.runsInWidget) {
  Script.setWidget(widget);
} else {
  widget.presentMedium();
}
Script.complete();

2-2. json 파일을 생성하는 스크립트

const fm = FileManager.iCloud();
const path = fm.joinPath(fm.documentsDirectory(), "running-log.json"); // json 이름을 이곳에서 변경

if (!fm.fileExists(path)) {
  fm.writeString(path, JSON.stringify([]));  // 빈 배열 저장
  console.log("파일 생성 완료");
} else {
  console.log("파일 이미 있음");
}

Script.complete();

2-3. json 파일에 기록을 추가하는 스크립트


function getLocalDateString(date = new Date()) {
  const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
  return local.toISOString().slice(0, 10);
}

const FILE_NAME = "running-log.json";
const fm = FileManager.iCloud();
const path = fm.joinPath(fm.documentsDirectory(), FILE_NAME);

await fm.downloadFileFromiCloud(path);

let data = [];
if (fm.fileExists(path)) {
  const raw = fm.readString(path);
  data = JSON.parse(raw);
}

const today = getLocalDateString(); // 현지 날짜로 계산
if (data.includes(today)) {
  data = data.filter(d => d !== today); // Remove
} else {
  data.push(today); // Add
}

fm.writeString(path, JSON.stringify(data));
Script.complete();

3. 위젯 및 단축어

3-1. 위젯을 홈 화면에 추가한다.

  • 해당 스크립트는 가로로 긴 위젯에 맞추어 제작되었다.

    홈 화면에서 위젯 추가를 누르고 Scriptable을 검색해 클릭한다. 가로로 긴 위젯을 선택한다. Run Script에서 Tracker 스크립트를 선택한다.(1) Run Script에서 Tracker 스크립트를 선택한다.(2)

3-2. 단축어를 추가한다.

  • Parameter 부분은 비워둔다.
  • Refresh all widgets도 추가해 주어야 결과가 바로 트래커에 반영된다.
  • 단축어를 실수로 눌렀을 경우, 한 번 더 누르면 기록이 취소된다.

4. 완성된 화면

색 조합은 글 하단을 참고할 것 각 단축어를 누르면 트래커에 반영이 된다.

5. 사용해 보고 느낀 점

이렇게 한 번만 세팅해 주고 나면 반평생동안 쓸 수 있고, json 파일에 기록에 남기 때문에, 어디로든 데이터 이전이 가능하다는 장점이 있다. 단축어를 사용해야 한다는 번거로움이 있지만 아직까지는 크게 신경 쓰이는 수준은 아니다. 또다른 단축어 기능을 활용해 특정 시간에 트래킹을 했는지 여부를 묻는 알람을 보내는 것도 좋은 방법이겠다.

색상 조합

  • Tracker 스크립트에 해당 코드를 찾아 변경해주면 된다.
// Appearance (브라운 오렌지 톤)
const BG_COLOR = "#5C3B2E"; // 짙은 브라운 오렌지 배경
const COLOR_FILLED = new Color("#FFB347"); // 밝은 오렌지
const COLOR_UNFILLED = new Color("#FFB347", 0.3); // 흐린 오렌지 (투명도 30%)

// Appearance (산뜻한 네온 민트 그린 톤)
const BG_COLOR = "#0A2F1C"; // 짙은 녹색 배경
const COLOR_FILLED = new Color("#8BF7C4"); // 네온 민트색
const COLOR_UNFILLED = new Color("#8BF7C4", 0.35); // 흐린 민트색 (투명도 35%)

// Appearance (연한 파란색 톤)
const BG_COLOR = "#1C2A42"; // 푸른 회색 배경
const COLOR_FILLED = new Color("#B3E7FD"); // 하늘빛 블루
const COLOR_UNFILLED = new Color("#B3E7FD", 0.3); // 흐린 블루 (투명도 30%)