[HammerSpoon] 忘れがちな勤怠打刻を忘れないようにする努力
はじめに
弊社では勤怠管理システムを使っています。毎朝 お仕事を始めるタイミングで、出勤を打刻しないといけません。
でもこの打刻、忘れちゃうんですよね。忘れても修正打刻ができるんですが、ちゃんと打刻できた方がいいはず。
忘れない努力をします。
目標
- 何らかで打刻の必要性を思い出す
→ 最低条件。 - その日、打刻したかどうか一目でわかる
→ 夕方くらいに、「あれ...打刻したっけ...」を防ぐ。 - 再起動後にも出てくる
→ 事故防止。 - 自然に打刻できる
→ 後回しにして結局忘れるを防ぐ
おことわり
- macOSユーザー向けです。
- リンクがアンカー付きなので、プレビューだけで区別が付きづらい箇所があります。できるだけ補足します。
HammerSpoonとは
macOS用のオープンソース自動化ツールです。Luaスクリプトでキーボードショートカット、ウィンドウ操作、画面表示など、OSレベルの操作をカスタマイズできます。
本記事では、HammerSpoonのcanvas機能を主に使って、タイムカード打刻を忘れないようにしていきます。
インストール
Homebrewで入れちゃいます。
brew install --cask hammerspoon
特にこれ以上やることはないです。素晴らしい。
再起動時にも起動してもらうには、
画面上部 メニューバー > HammerSpoon > Preferences... > Launch HammerSpoon at login にチェックを入れておきます。
設定スクリプトファイル
設定スクリプトファイルを開く方法は2つあります。どっちでも同じです。
- 画面上部 メニューバー > HammerSpoon > Open Config
- 好きなテキストエディタで ~/.hammerspoon/init.lua を開く
この設定スクリプトが、HammerSpoonのエントリポイントになります。直接モリモリ書いてもいいし、分割してもOKです。
分割は、 ~/.hammerspoon/modules/sample1.lua に分割した場合
-- init.lua
require('modules.sample1')
とすることで、読み込むことができます。モジュール側の トップレベルのコードは即時実行されます。
この記事は、init.luaにモリモリ書いていく前提で書いています。
更新の反映
以降、設定ファイルを書き換えたら以下手順で設定を反映してください。
- 画面上部 メニューバー > HammerSpoon > Reload Config
設定内容
1. canvasを作る
canvasは画面上に図形や文字を自由に描画できるAPIです。
以下は単純な長方形の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です。

細かく見ていきます。
local screen = hs.screen.mainScreen():fullFrame()
メインディスプレイの画面全体(メニューバー・Dock含む)の座標とサイズを取得しています。
↓ 参考: frame() だと、メニューバーやDockを除いた領域になります。
-- 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
↓ 使えるtype, trackMouseUpについてはこちらに記載があります
そして、最後に canvas:show() でcanvasを描画します。これを忘れると何も出てこないので、注意が必要です。
↓ 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
↓ urlevent
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
また、矩形描画の部分でテキストも書くように書き直しました。
最終的なスクリプト
最終的なスクリプトはこのようになりました。
-- 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トップにしています

- 打刻していないと点滅して教えてくれる
- クリックすると打刻できる画面に飛ぶ
- 打刻画面に飛ぶと緑になるので、夕方、押したかどうかわからなくならない
- 日付が変わるとまた点滅する
最後に
目的は果たせました。が、退勤の打刻、どうするか悩みますね。退勤の打刻を忘れるときは達成感と共にMacBookのフタをターンッ!!と閉めているので、どうしようもない気がしなくもないです。18時になったらまた点滅するようにするとかですかね。。
HammerSpoon、他にもいろんなAPIがあり、思いつくことなら大体は実現できそうな雰囲気なので、今後もアイデアが湧いたらブログ書こうと思います。
あとたまにcanvasがいなくなります。いなくなったら Reload Config です。






