[HammerSpoon] 忘れがちな勤怠打刻を忘れないようにする努力

[HammerSpoon] 忘れがちな勤怠打刻を忘れないようにする努力

HammerSpoonなる強力なMac操作ツールを使って、勤怠管理の打刻漏れを防ぐ!
2026.03.02

はじめに

弊社では勤怠管理システムを使っています。毎朝 お仕事を始めるタイミングで、出勤を打刻しないといけません。

でもこの打刻、忘れちゃうんですよね。忘れても修正打刻ができるんですが、ちゃんと打刻できた方がいいはず。

忘れない努力をします。

目標

  • 何らかで打刻の必要性を思い出す
    → 最低条件。
  • その日、打刻したかどうか一目でわかる
    → 夕方くらいに、「あれ...打刻したっけ...」を防ぐ。
  • 再起動後にも出てくる
    → 事故防止。
  • 自然に打刻できる
    → 後回しにして結局忘れるを防ぐ

おことわり

  • macOSユーザー向けです。
  • リンクがアンカー付きなので、プレビューだけで区別が付きづらい箇所があります。できるだけ補足します。

HammerSpoonとは

macOS用のオープンソース自動化ツールです。Luaスクリプトでキーボードショートカット、ウィンドウ操作、画面表示など、OSレベルの操作をカスタマイズできます。

https://www.hammerspoon.org/

本記事では、HammerSpoonのcanvas機能を主に使って、タイムカード打刻を忘れないようにしていきます。

インストール

Homebrewで入れちゃいます。

brew install --cask hammerspoon

特にこれ以上やることはないです。素晴らしい。

再起動時にも起動してもらうには、

画面上部 メニューバー > HammerSpoon > Preferences... > Launch HammerSpoon at login にチェックを入れておきます。

設定スクリプトファイル

設定スクリプトファイルを開く方法は2つあります。どっちでも同じです。

  1. 画面上部 メニューバー > HammerSpoon > Open Config
  2. 好きなテキストエディタで ~/.hammerspoon/init.lua を開く

この設定スクリプトが、HammerSpoonのエントリポイントになります。直接モリモリ書いてもいいし、分割してもOKです。
分割は、 ~/.hammerspoon/modules/sample1.lua に分割した場合

-- init.lua
require('modules.sample1')

とすることで、読み込むことができます。モジュール側の トップレベルのコードは即時実行されます。
この記事は、init.luaにモリモリ書いていく前提で書いています。

更新の反映

以降、設定ファイルを書き換えたら以下手順で設定を反映してください。

  • 画面上部 メニューバー > HammerSpoon > Reload Config

設定内容

1. canvasを作る

canvasは画面上に図形や文字を自由に描画できるAPIです。

https://www.hammerspoon.org/docs/hs.canvas.html

以下は単純な長方形のcanvasを作成するluaスクリプトです。

-- init.lua

local screen = hs.screen.mainScreen():fullFrame()
local w, h = 200, 40
local canvas = hs.canvas.new({
  x = screen.w - w - 10,
  y = screen.h - h - 5,
  w = w,
  h = h,
})

canvas:appendElements({
  {
    type = "rectangle",
    fillColor =  { red = 0.9, green = 0.75, blue = 0.1, alpha = 0.9 },
    roundedRectRadii = { xRadius = 8, yRadius = 8 },
    trackMouseUp = true,
  },
})

canvas:show()

これで、Reload Config して、画面右下に黄色の長方形が出てくればOKです。

step1: 黄色の長方形


細かく見ていきます。

local screen = hs.screen.mainScreen():fullFrame()

メインディスプレイの画面全体(メニューバー・Dock含む)の座標とサイズを取得しています。
https://www.hammerspoon.org/docs/hs.screen.html#fullFrame

↓ 参考: frame() だと、メニューバーやDockを除いた領域になります。
https://www.hammerspoon.org/docs/hs.screen.html#frame

-- canvas生成部分

local w, h = 200, 40
local canvas = hs.canvas.new({
  x = screen.w - w - 10,
  y = screen.h - h - 5,
  w = w,
  h = h,
})

width (幅) と height (高さ) を定義し、canvasを生成します。canvasの右側に10px, 下に5pxのマージンをとっています。

-- canvasに要素を追加する

canvas:appendElements({
  {
    type = "rectangle",
    fillColor =  { red = 0.9, green = 0.75, blue = 0.1, alpha = 0.9 },
    roundedRectRadii = { xRadius = 8, yRadius = 8 },
    trackMouseUp = true,
  },
})

-- canvasを表示する
canvas:show()

続いて、canvasに要素を追加します。typeにはここで使ったrectangle (矩形) 以外にも、楕円やテキストなどが使えます。
また、trackMouseUpを有効にすることで、クリックして操作が可能になります。(クリックした時の挙動の定義は後述)

↓ appendElements
https://www.hammerspoon.org/docs/hs.canvas.html#appendElements

↓ 使えるtype, trackMouseUpについてはこちらに記載があります
https://www.hammerspoon.org/docs/hs.canvas.html#attributes

そして、最後に canvas:show() でcanvasを描画します。これを忘れると何も出てこないので、注意が必要です。

↓ show
https://www.hammerspoon.org/docs/hs.canvas.html#show

2. クリックでタイムカードのURLに飛べるようにする

続いて、クリックした際にタイムカード打刻のURLに飛べるようにします。
コードから。

-- タイムカード打刻URL
local TIMECARD_URL = "https://hogehoge"

-- クリック時のコールバック
canvas:mouseCallback(function(_, event)
  if event == "mouseUp" then
    hs.urlevent.openURL(TIMECARD_URL)
  end
end)

これだけです。trackMouseUp をtrueにしたので、コールバックが拾えるようになっています。コールバックを受けて、urleventを発火させればOKです。

↓ mouseCallback
https://www.hammerspoon.org/docs/hs.canvas.html#mouseCallback

↓ urlevent
https://www.hammerspoon.org/docs/hs.urlevent.html

3. 色で状態を確認できるようにする

最後に、色で状態が一目瞭然なcanvasを作っていきます。
以下のように色を決めました。

状態
未打刻 黄色 + 赤 の点滅
打刻済み

これを実現するコードが以下です。

-- 色定義
local COLOR_YELLOW = { red = 0.9, green = 0.75, blue = 0.1, alpha = 0.9 }
local COLOR_GREEN  = { red = 0.2, green = 0.7, blue = 0.3, alpha = 0.85 }
local COLOR_RED    = { red = 1, green = 0.2, blue = 0.2, alpha = 0.9 }
local TEXT_WHITE   = { white = 1 }
local TEXT_BLACK   = { red = 0, green = 0, blue = 0 }

-- 打刻状態管理
local punchedDate = nil
local blinkTimer = nil
local blinkState = true

-- * 矩形追加部分を書き直す *
canvas:appendElements({
  {
    type = "rectangle",
    fillColor = COLOR_YELLOW,
    roundedRectRadii = { xRadius = 8, yRadius = 8 },
    trackMouseUp = true,
  },
  {
    type = "text",
    text = "⏰ タイムカード打刻",
    textColor = TEXT_BLACK,
    textSize = 16,
    textAlignment = "center",
    frame = { x = 0, y = 8, w = 200, h = 30 },
    trackMouseUp = true,
  },
})
canvas:level(hs.canvas.windowLevels.floating)
canvas:behavior(hs.canvas.windowBehaviors.canJoinAllSpaces)

-- 年月日取得
local function today()
  return os.date("%Y-%m-%d")
end

-- 打刻
local function isPunched()
  return punchedDate == today()
end

-- 点滅停止
local function stopBlink()
  if blinkTimer then
    blinkTimer:stop()
    blinkTimer = nil
    blinkState = true
  end
end

-- 点滅開始
local function startBlink()
  if blinkTimer then return end
  blinkTimer = hs.timer.doEvery(0.5, function()
    blinkState = not blinkState
    if blinkState then
      canvas[1].fillColor = COLOR_RED
      canvas[2].textColor = TEXT_WHITE
    else
      canvas[1].fillColor = COLOR_YELLOW
      canvas[2].textColor = TEXT_BLACK
    end
  end)
end

local function updateState()
  if not canvas then return end
  if isPunched() then
    stopBlink()
    canvas[1].fillColor = COLOR_GREEN
    canvas[2].textColor = TEXT_WHITE
  else
    startBlink()
  end
end

canvas:mouseCallback(function(_, event)
  if event == "mouseUp" then
    hs.urlevent.openURL(TIMECARD_URL)
    -- 2行追加
    punchedDate = today()
    updateState()
  end
end)

startBlink() -- 起動時は未打刻なので点滅開始

ちょっと長いですが、変なことはしてません。HammerSpoonっぽいところを抜粋します。


-- 点滅開始
local function startBlink()
  if blinkTimer then return end
  blinkTimer = hs.timer.doEvery(0.5, function()
    blinkState = not blinkState
    if blinkState then
      canvas[1].fillColor = COLOR_RED
      canvas[2].textColor = TEXT_WHITE
    else
      canvas[1].fillColor = COLOR_YELLOW
      canvas[2].textColor = TEXT_BLACK
    end
  end)
end

ここでは、timer.doEveryイベントを使って点滅させています。doEveryはコールバックベースの繰り返しタイマーで、時間で動くwhile関数みたいなイメージです。
0.5秒おきに、canvasの矩形とテキストそれぞれの色を切り替えています。

↓ doEvery
https://www.hammerspoon.org/docs/hs.timer.html#doEvery

また、矩形描画の部分でテキストも書くように書き直しました。

最終的なスクリプト

最終的なスクリプトはこのようになりました。

-- init.lua

-- タイムカード打刻URL
local TIMECARD_URL = "hoge"

-- 色定義
local COLOR_YELLOW = { red = 0.9, green = 0.75, blue = 0.1, alpha = 0.9 }
local COLOR_GREEN  = { red = 0.2, green = 0.7, blue = 0.3, alpha = 0.85 }
local COLOR_RED    = { red = 1, green = 0.2, blue = 0.2, alpha = 0.9 }
local TEXT_WHITE   = { white = 1 }
local TEXT_BLACK   = { red = 0, green = 0, blue = 0 }

-- canvas生成
local w, h = 200, 40
local canvas = nil
local function createCanvas()
  local screen = hs.screen.mainScreen():fullFrame()
  local c = hs.canvas.new({
    x = screen.w - w - 10,
    y = screen.h - h - 5,
    w = w,
    h = h,
  })
  c:appendElements({
    {
      type = "rectangle",
      fillColor = COLOR_YELLOW,
      roundedRectRadii = { xRadius = 8, yRadius = 8 },
      trackMouseUp = true,
    },
    {
      type = "text",
      text = "⏰ タイムカード打刻",
      textColor = TEXT_BLACK,
      textSize = 16,
      textAlignment = "center",
      frame = { x = 0, y = 8, w = 200, h = 30 },
      trackMouseUp = true,
    },
  })
  c:level(hs.canvas.windowLevels.floating)
  c:behavior(hs.canvas.windowBehaviors.canJoinAllSpaces)
  return c
end

-- 打刻状態管理
local punchedDate = nil
local blinkTimer = nil
local blinkState = true

-- 年月日取得
local function today()
  return os.date("%Y-%m-%d")
end

-- 打刻済みか確認
local function isPunched()
  return punchedDate == today()
end

-- 点滅停止
local function stopBlink()
  if blinkTimer then
    blinkTimer:stop()
    blinkTimer = nil
    blinkState = true
  end
end

-- 点滅開始
local function startBlink()
  if blinkTimer or not canvas then return end
  blinkTimer = hs.timer.doEvery(0.5, function()
    blinkState = not blinkState
    if blinkState then
      canvas[1].fillColor = COLOR_RED
      canvas[2].textColor = TEXT_WHITE
    else
      canvas[1].fillColor = COLOR_YELLOW
      canvas[2].textColor = TEXT_BLACK
    end
  end)
end

-- 状態更新
local function updateState()
  if not canvas then return end
  if isPunched() then
    stopBlink()
    canvas[1].fillColor = COLOR_GREEN
    canvas[2].textColor = TEXT_WHITE
  else
    startBlink()
  end
end

-- canvasをリセットする
local function setupCanvas()
  if canvas then
    stopBlink()
    canvas:delete()
  end
  canvas = createCanvas()
  canvas:mouseCallback(function(_, event)
    if event == "mouseUp" then
      hs.urlevent.openURL(TIMECARD_URL)
      punchedDate = today()
      updateState()
    end
  end)
  canvas:show()
  updateState()
end

-- 初期表示
setupCanvas()

-- 画面構成変更時にcanvasを再配置
hs.screen.watcher.new(function()
  hs.timer.doAfter(1, function()
    setupCanvas()
  end)
end):start()

-- スリープ復帰時にcanvasを再配置
hs.caffeinate.watcher.new(function(event)
  if event == hs.caffeinate.watcher.screensDidUnlock
    or event == hs.caffeinate.watcher.screensDidWake then
    hs.timer.doAfter(2, function()
      setupCanvas()
    end)
  end
end):start()

-- 日付が変わったら点滅再開
hs.timer.doEvery(60, function()
  updateState()
end)

細かい追加がありますが、概ね各ステップで紹介した通りに作っています。

成果

  • ※ 遷移先はDevIOトップにしています

result

  • 打刻していないと点滅して教えてくれる
  • クリックすると打刻できる画面に飛ぶ
  • 打刻画面に飛ぶと緑になるので、夕方、押したかどうかわからなくならない
  • 日付が変わるとまた点滅する

最後に

目的は果たせました。が、退勤の打刻、どうするか悩みますね。退勤の打刻を忘れるときは達成感と共にMacBookのフタをターンッ!!と閉めているので、どうしようもない気がしなくもないです。18時になったらまた点滅するようにするとかですかね。。

HammerSpoon、他にもいろんなAPIがあり、思いつくことなら大体は実現できそうな雰囲気なので、今後もアイデアが湧いたらブログ書こうと思います。

あとたまにcanvasがいなくなります。いなくなったら Reload Config です。

この記事をシェアする

FacebookHatena blogX

関連記事