Huaiyao Jin

Huaiyao Jin

Notion+Scriptable+快捷指令,每天完成最重要的事

每天完成最重要的几件事,这一天就算没有虚度。

每天打开手机,看一眼,就知道还有什么事没做,简单而清晰。

点击一下按钮,输入内容,即可完成打卡。

实现方式还是利用自己的“三板斧”:Notion、Scriptable和Apple快捷指令。

“今日打卡”部分

Notion api+Scriptable app,这个之前也提到过,需要做的就是明确一下需求和实现思路,脚本部分让AI帮忙即可。

我的实现思路是调用Notion api,找到当天的所有记录,如果记录里符合特定的tag就表明这一项已经完成了。

const TASK_TAGS = {
  "晨间日记":        "Diary",
  "背单词+多邻国":    "Vocabulary",
  "运动":            "Workout",
  "平板支撑":        "Plank",
  "阅读":            "Reading",
  "学习数据库+编程": "DBLearning",
  "总结工作":        "WorkingSummary",
  "等等的每日一照":        "DengDengPhoto"
};

Scriptable的脚本:

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: deep-gray; icon-glyph: magic;
// icon-color: blue; icon-glyph: check-square;

// ========= 今日要打卡的项目 =========
const TASKS = [
  "晨间日记",
  "背单词+多邻国",
  "运动",
  "平板支撑",
  "阅读",
  "学习数据库+编程",
  "总结工作",
  "等等的每日一照"
];

// 每个项目对应的 Notion Tag 名
const TASK_TAGS = {
  "晨间日记":        "Diary",
  "背单词+多邻国":          "Vocabulary",
  "运动":            "Workout",
  "平板支撑":        "Plank",
  "阅读":            "Reading",
  "学习数据库+编程": "DBLearning",
  "总结工作":        "WorkingSummary",
  "等等的每日一照":        "DengDengPhoto"
};

// 反向映射:tag -> taskName
const TAG_TO_TASK = {};
for (const [task, tag] of Object.entries(TASK_TAGS)) {
  TAG_TO_TASK[tag] = task;
}

// ========= 颜色(和你月视图一致) =========
const COLOR_BLUE = new Color("#1a7ee8d4");  // 已完成
const COLOR_GRAY = new Color("#DFE3EB");    // 未完成
const COLOR_TEXT = Color.black();

const BOX_SIZE   = 18;
const BOX_RADIUS = 4;

// ========= Notion 配置(改这里) =========
const NOTION_API_URL = "https://api.notion.com/v1/databases/xxx/query"; // ← 换成你的 Daily 数据库 query URL
const NOTION_TOKEN   = "ntn_xxx";   
const NOTION_VERSION = "2022-06-28";

// 分页
const PAGE_SIZE         = 100;
const SAFETY_MULTIPLIER = 3;
const MAX_PAGES         = SAFETY_MULTIPLIER;

// ========= 通用工具 =========
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

function pad(n) { return n < 10 ? "0" + n : "" + n; }

function formatDateKey(d) {
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}

// 今天 00:00 ~ 明天 00:00
function getTodayRange() {
  const now  = new Date();
  const y    = now.getFullYear();
  const m    = now.getMonth();
  const d    = now.getDate();
  const start = new Date(y, m, d);
  const end   = new Date(y, m, d + 1);
  return { start, end };
}

// ========= Notion 请求封装 =========
async function notionQuery(body) {
  let req = new Request(NOTION_API_URL);
  req.method = "POST";
  req.headers = {
    "Authorization": `Bearer ${NOTION_TOKEN}`,
    "Content-Type": "application/json",
    "Notion-Version": NOTION_VERSION
  };
  req.body = JSON.stringify(body);

  const res = await req.loadJSON();
  return {
    results:     res.results     || [],
    has_more:    res.has_more    || false,
    next_cursor: res.next_cursor || null
  };
}

// 从 page 中抽日期:优先 Date / Time,其次 created_time
function extractDailyDate(page) {
  try {
    const d1 = page.properties?.Date?.date?.start;
    if (d1) return new Date(d1);
  } catch (_) {}

  try {
    const d2 = page.properties?.Time?.date?.start;
    if (d2) return new Date(d2);
  } catch (_) {}

  try {
    const d3 = page.properties?.Time?.created_time;
    if (d3) return new Date(d3);
  } catch (_) {}

  if (page.created_time) return new Date(page.created_time);
  if (page.last_edited_time) return new Date(page.last_edited_time);
  return null;
}

// ========= 核心:从 Notion 拉取“今天的所有条目”,看 tag 是否命中 =========
async function loadTodayCompletedTasks() {
  const { start, end } = getTodayRange();
  const completedSet = new Set();

  // 构造 tag 过滤:Tags 包含任意一个对应 tag 即可
  const tagFilters = Object.values(TASK_TAGS).map(tagName => ({
    property: "Tags",
    multi_select: { contains: tagName }
  }));

  let hasMore    = true;
  let nextCursor = null;
  let pageCount  = 0;

  while (hasMore && pageCount < MAX_PAGES) {
    const payload = {
      filter: {
        or: tagFilters   // Tags 包含任何一个目标 tag
      },
      sorts: [
        { timestamp: "created_time", direction: "descending" }
      ],
      page_size: PAGE_SIZE
    };

    if (nextCursor) payload.start_cursor = nextCursor;

    const { results, has_more, next_cursor } = await notionQuery(payload);
    pageCount += 1;
    hasMore    = !!has_more;
    nextCursor = next_cursor || null;

    let seenOlderThanToday = false;

    for (const page of results || []) {
      const d = extractDailyDate(page);
      if (!d) continue;

      if (d >= start && d < end) {
        // 今天的记录:检查它有哪些 tag
        const tagList = page.properties?.Tags?.multi_select || [];
        for (const t of tagList) {
          const tagName = t.name;
          const taskName = TAG_TO_TASK[tagName];
          if (taskName) {
            completedSet.add(taskName);  // 该项目视为已完成
          }
        }
      } else if (d < start) {
        // 已经翻到昨天之前,可以停了
        seenOlderThanToday = true;
      }
    }

    if (seenOlderThanToday) break;

    if (hasMore) await sleep(150);
  }

  return completedSet;
}

// ========= 拉取今天已完成的项目 =========
let COMPLETED_TODAY = new Set();
try {
  COMPLETED_TODAY = await loadTodayCompletedTasks();
} catch (e) {
  console.error("Load Notion failed:", e);
  COMPLETED_TODAY = new Set();
}

// 某个项目是否完成
function isTaskDone(taskName) {
  return COMPLETED_TODAY.has(taskName);
}

// ========= 构建 Widget(左右两列,齐头对齐) =========
let widget = new ListWidget();
// 左边距调大一点,美观
widget.setPadding(10, 26, 10, 12);
widget.backgroundColor = Color.clear();

// 标题
let titleText = widget.addText("今日打卡");
titleText.font = Font.boldSystemFont(16);
titleText.textColor = COLOR_TEXT;
widget.addSpacer(6);

// 左右列容器
let grid = widget.addStack();
grid.layoutHorizontally();

// 左列
let leftCol = grid.addStack();
leftCol.layoutVertically();
leftCol.spacing = 4;

// 中间间隔
grid.addSpacer(24);

// 右列
let rightCol = grid.addStack();
rightCol.layoutVertically();
rightCol.spacing = 4;

// 每列的行数
const rowsPerCol = Math.ceil(TASKS.length / 2);

// 左列放 0,2,4,6 ;右列放 1,3,5,7
for (let i = 0; i < rowsPerCol; i++) {
  const leftIndex = 2 * i;
  if (leftIndex < TASKS.length) {
    const name = TASKS[leftIndex];
    addTaskRow(leftCol, name, isTaskDone(name));
  }

  const rightIndex = 2 * i + 1;
  if (rightIndex < TASKS.length) {
    const name = TASKS[rightIndex];
    addTaskRow(rightCol, name, isTaskDone(name));
  }
}

// 单行:方块 + 文本
function addTaskRow(parentStack, taskName, checked) {
  let row = parentStack.addStack();
  row.layoutHorizontally();
  row.spacing = 6;
  row.centerAlignContent();

  let box = row.addStack();
  box.size = new Size(BOX_SIZE, BOX_SIZE);
  box.cornerRadius = BOX_RADIUS;
  box.centerAlignContent();

  if (checked) {
    box.backgroundColor = COLOR_BLUE;
    let check = box.addText("✓");
    check.font = Font.systemFont(12);
    check.textColor = Color.white();
    check.centerAlignText();
  } else {
    box.backgroundColor = COLOR_GRAY;
  }

  let label = row.addText(taskName);
  label.font = Font.systemFont(13);
  label.textColor = COLOR_TEXT;
  label.lineLimit = 1;
}

// ========= 完成 =========
if (config.runsInWidget) {
  Script.setWidget(widget);
} else {
  await widget.presentMedium();
}
Script.complete();

点击一下看看效果。

然后设置桌面小组件,效果如文中的第一张图。

按钮部分

使用Notion api+快捷指令app。

这里有两种按钮,一种是不需要输入内容的,例如“晨间日记”。另一种是需要输入具体内容的,比如“运动”。

第一种,内容和标记这两个变量的值都是固定的:

第二种,内容从固定值Text改为“Ask for Input”即可,点击以后会提示输入,效果如文中的第二张图。

搞定!