Add and view time entries using the timetracker CLI
Use /time-entry to explicitly invoke this skill. You can also trigger it with natural language, but using /time-entry is preferred — it guarantees the right workflow is used and you can pass your request directly as an argument:
/time-entry log 4h GXO today — Oracle work
/time-entry show this week
/time-entry email this week's report to my boss
/time-entry fix my last entry — it was 3h not 2
Add, view, report on, and export time entries via the timetracker Go binary at ~/.claude/skills/time-entry/timetracker. No Python or jq required for core operations. Excel export uses a separate export.py (requires uv).
~/.config/timetracker.json
data_file: path to the entries JSON filebackup_file: path to the daily .zip backupaccounts: list of known project accountsspreadsheet_file: path to the Excel timesheet (optional, set on first export)last_backup_date: tracks when the last backup ran (managed automatically)data_file~/.claude/skills/time-entry/timetracker --version 2>&1 && echo "ok" || echo "missing"
If missing, tell the user:
"The timetracker binary is missing. Rebuild it from source:
cd ~/.claude/skills/time-entry/go-src && go build -o ~/.claude/skills/time-entry/timetracker .Requires Go 1.21+. See BUILD.md in that directory."
Do not proceed if the binary is unavailable.
test -f ~/.config/timetracker.json && echo "exists" || echo "missing"
If missing, ask the user where to store their data. Optionally detect OneDrive to offer as a suggestion:
ls -d ~/Library/CloudStorage/OneDrive-*/ 2>/dev/null | head -1
Ask the user:
"Where should your time entries file be stored? (e.g. ~/Documents/timetracker/entries.json) Tip: storing it in OneDrive keeps it backed up and synced across machines — [DETECTED_ONEDRIVE_PATH]timetracker/entries.json is a good choice if you use OneDrive."
"Where should daily backups be saved? (e.g. ~/Documents/timetracker/backups/entries-backup.zip)"
If OneDrive was not detected, omit the OneDrive tip. Accept any path the user provides — do not require or default to OneDrive.
Then create config and data file:
~/.claude/skills/time-entry/timetracker config init \
--data-file "DATA_FILE_PATH" \
--backup-file "BACKUP_FILE_PATH"
If the user wants to log multiple entries at once (e.g. "log my whole week", "enter several entries", or provides a list), enter batch mode:
"These projects aren't in your list: X, Y. Add them all?"Ready to log:
2026-03-31 | GXO | Billable | 4.0h — Oracle work
2026-03-31 | FCB | Billable | 2.0h
2026-04-01 | Arrow | Billable | 6.0h — infra review
Log all 3 entries? (yes / edit / cancel)
timetracker batch call:Build the new_entries list from all confirmed entries, then run:
Pass a JSON array of entry objects:
~/.claude/skills/time-entry/timetracker batch '[
{"date":"DATE1","project":"PROJECT1","work_type":"WORK_TYPE1","hours":HOURS1,"notes":"NOTES1"},
{"date":"DATE2","project":"PROJECT2","work_type":"WORK_TYPE2","hours":HOURS2,"notes":"NOTES2"}
]'
If the user provides partial info like "log 4h QTS today" or "8h GXO - bug fixes":
Read the current accounts list with:
~/.claude/skills/time-entry/timetracker accounts list
Present the list to the user and ask them to pick one. Always include "Other (new project)" as the last option.
Example prompt:
Which project?
- GXO
- FCB
- IHG
- Other (new project)
If the user picks "Other" or types a name not in the list:
"Add '[name]' to your accounts list for future use?"~/.claude/skills/time-entry/timetracker config add-account "NEW_ACCOUNT_NAME"
If the accounts list is empty (first use), skip the selection and just ask:
"What project is this for?" Then offer to save it:
"Save '[name]' to your accounts list?"
Billable, Non-Billable, PTOAsk "Hours:" and let the user type directly (e.g. 4, 8, 1.5). Required, numeric.
Optional. Ask "Notes: (optional)" and allow the user to skip with enter.
Use the pre-written script — replace DATE, PROJECT, WORK_TYPE, HOURS, NOTES with actual values:
~/.claude/skills/time-entry/timetracker add DATE PROJECT WORK_TYPE HOURS "NOTES"
Example:
~/.claude/skills/time-entry/timetracker add 2026-04-03 FCB Billable 0.5 "Planning"
Show the output line to the user: Added: [date] | [project] | [type] | [hours]h
~/.claude/skills/time-entry/timetracker backup
Trigger phrases: "edit last entry", "fix my last entry", "change the last one", "edit first entry", "edit entry 2", "fix today's GXO entry", "amend", "update entry"
Support these selectors:
last — the most recently created entry (highest created_at, or last in array if missing)first — the oldest entry (lowest created_at, or first in array)N — Nth from the end (e.g. "edit entry 2" = second-to-last)Step 1: Show the candidate entry and ask what to change.
~/.claude/skills/time-entry/timetracker amend show last
# or: show first | show 2 | show ENTRY_ID
Show this to the user and ask: "What would you like to change? (date / project / hours / notes / work_type)"
Step 2: Apply the change and write back.
~/.claude/skills/time-entry/timetracker amend update ENTRY_ID field=value [field=value ...]
# Examples:
# timetracker amend update abc123 hours=3.0
# timetracker amend update abc123 notes="Oracle 9 schema work"
# timetracker amend update abc123 hours=3.0 notes="Updated" project=GXO
Step 3: Confirm the change to the user, then run the daily backup.
User: fix my last entry — it was 3 hours not 2
→ Updated: 2026-04-03 | Acrisure | Billable | 3.0h — Working on Palantir integration
updated_at: 2026-04-03T15:42:10
User: edit today's GXO entry, notes should be "Oracle 9 schema work"
→ Updated: 2026-04-03 | GXO | Billable | 1.0h — Oracle 9 schema work
updated_at: 2026-04-03T15:43:55
Trigger phrases: "pull from calendar", "pull from calendar and email", "sync from calendar", "import from calendar", "check calendar for entries", "pull this week from calendar", "pull from calendar and email"
⚠️ Desktop requirement: This feature requires Calendar.app and Mail.app installed and configured on macOS. Work accounts that will be queried are scoped to veza.com and servicenow.com domains. Personal calendars (rosariokevin.com, US Holidays, etc.) are automatically excluded.
Default: current week. Accept "this week", "last week", or a specific date. Compute week_start (Monday) and week_end (Friday) as YYYY-MM-DD strings.
~/.claude/skills/time-entry/timetracker list --week WEEK_START
Keep this list in mind for Step 4 deduplication.
Performance note: Uses a Swift + EventKit binary that is compiled locally on first use. EventKit does a single bulk fetch from the CalendarAgent daemon (~0.4s for a full week) vs AppleScript (~65s). The source lives at
~/.claude/skills/time-entry/pull_calendar.swift.
Check binary exists; compile from source if missing:
if test -x ~/.claude/skills/time-entry/pull_calendar; then
echo "binary_ok"
elif command -v swiftc &>/dev/null; then
echo "Compiling pull_calendar (first use)..."
swiftc ~/.claude/skills/time-entry/pull_calendar.swift \
-o ~/.claude/skills/time-entry/pull_calendar \
&& echo "compiled_ok" || echo "compile_failed"
else
echo "swiftc_missing"
fi
If the output is swiftc_missing, tell the user:
"The calendar import feature requires Xcode Command Line Tools. Install with:
xcode-select --installThen try again." Do not proceed without the binary.
Run it — replace WEEK_START (Monday, e.g. 2026-03-30) and WEEK_END (Saturday, e.g. 2026-04-04):
~/.claude/skills/time-entry/pull_calendar WEEK_START WEEK_END 2>&1
Output format (one line per event): calendarName|title|start|end|isAllDay
On first run macOS will prompt for Calendar permission — grant it once and it persists.
To rebuild the binary (e.g. after a macOS update breaks it):
swiftc ~/.claude/skills/time-entry/pull_calendar.swift \
-o ~/.claude/skills/time-entry/pull_calendar
Filter rules — include only events that:
Classify as customer events if the title:
CustomerName | Veza, CustomerName/Veza, CustomerName<>Veza, CustomerName – VezaExclude as internal/non-billable if the title matches:
For each customer event, identify the most likely account from the known accounts list. If the account can't be determined with confidence, flag it with ? and ask the user.
Note: Email-based entries are lower confidence than calendar entries. Always flag them separately and let the user decide. Emails show communication happened, not necessarily billable work time.
Use AppleScript to read emails from work accounts for the week:
osascript << 'APPLESCRIPT'
set startDate to date "WEEK_START_DATE"
set output to ""
tell application "Mail"
repeat with acct in accounts
set acctEmail to email addresses of acct
-- Only query veza.com and servicenow.com accounts
repeat with addr in acctEmail
if addr contains "veza.com" or addr contains "servicenow.com" then
set msgs to (messages of inbox of acct whose date received >= startDate)
repeat with m in msgs
set output to output & (addr as string) & "|" & (subject of m) & "|" & ((date received of m) as string) & "|" & (sender of m) & "
"
end repeat
end if
end repeat
end repeat
end tell
return output
APPLESCRIPT
From the email results:
[from email] in the proposal tableFor each proposed entry:
⚠️ possible duplicate and include in the proposal but visually distinctceil(minutes / 30) * 0.5Show a numbered table:
Proposed entries from calendar (and email) — Week of WEEK_START:
# Date Project Hours Source Notes
1 2026-03-31 QTS 1.0h 📅 calendar QTS weekly working session
2 2026-03-31 Arrow 0.5h 📅 calendar OAA for ServiceNow discussion
3 2026-04-01 Acrisure 2.5h 📅 calendar Acrisure dev time
4 2026-04-01 Snowflake 1.0h 📅 calendar Custom app integration 🆕 new account
5 2026-04-02 IHG 0.5h 📧 email [from email] Customer email thread
...
Which entries should I log? (all / skip N / merge N and M / cancel)
Use batch write (see Batch Entry Mode section). For any 🆕 new accounts, add them to the accounts list first (see Accounts Management).
See Backup & Recovery section.
~/.claude/skills/time-entry/timetracker weekly # current week
~/.claude/skills/time-entry/timetracker weekly --week 2026-03-30 # specific week (Monday date)
~/.claude/skills/time-entry/timetracker day # today
~/.claude/skills/time-entry/timetracker day 2026-04-01 # specific date
~/.claude/skills/time-entry/timetracker list # recent entries
~/.claude/skills/time-entry/timetracker list --week 2026-03-30 # filter by week
~/.claude/skills/time-entry/timetracker list --date 2026-04-01 # filter by date
~/.claude/skills/time-entry/timetracker list --project GXO # filter by project
~/.claude/skills/time-entry/timetracker accounts list
See the "Add account" snippet in the Account/Project Selection section above.
User: log 4h QTS today - OAuth integration
Assistant: [shows account list, QTS selected]
→ Added: 2026-04-02 | QTS | Billable | 4.0h
User: log 2h on Acme Corp
Assistant: "Acme Corp isn't in your accounts list. Add it?"
User: yes
→ Added account: Acme Corp
→ Added: 2026-04-02 | Acme Corp | Billable | 2.0h
User: log 4h QTS
Assistant: "No config found. Where should your time entries be stored?"
User: ~/Documents/timetracker/entries.json
→ Config created. [proceeds to add entry]
User: log this week — Monday 4h GXO Oracle work, 2h FCB. Tuesday 6h Arrow infra review
Assistant: [parses 3 entries, shows summary table, asks to confirm]
→ Added: 2026-03-30 | GXO | Billable | 4.0h — Oracle work
→ Added: 2026-03-30 | FCB | Billable | 2.0h
→ Added: 2026-03-31 | Arrow | Billable | 6.0h — infra review
User: show this week's hours
→ Week of 2026-03-30
Total: 33.0h Billable: 33.0 | Non-Billable: 0 | PTO: 0
GXO: 9.0 | Arrow: 6.0 | FCB: 6.0 ...
User: export this week to spreadsheet
→ Exported 19 entries to ~/Library/CloudStorage/.../Timesheet.xlsx
Checks if a backup has already run today — no-op if so, otherwise writes the zip. Safe to call after every write.
~/.claude/skills/time-entry/timetracker backup
~/.claude/skills/time-entry/timetracker backup status
Always show the user what's in the backup before restoring. Then confirm before overwriting the live data.
~/.claude/skills/time-entry/timetracker backup restore
Ask the user to confirm, then restore:
~/.claude/skills/time-entry/timetracker backup restore --confirm
uv is only needed for Excel export (it pulls in openpyxl on demand).
which uv > /dev/null 2>&1 && echo "ok" || echo "missing"
If missing:
curl -LsSf https://astral.sh/uv/install.sh | sh && source ~/.zprofile
Read ~/.config/timetracker.json. If spreadsheet_file key is missing or empty, ask:
"Where should the timesheet spreadsheet be saved? (e.g. ~/Documents/Timesheet.xlsx)"
Then save it to config:
~/.claude/skills/time-entry/timetracker config set-spreadsheet "USER_PROVIDED_PATH"
Default: current week. Accept "this week", "last week", or a specific date (YYYY-MM-DD).
Compute week_start as the Monday of that week.
No duplicate entries: the script fully replaces the Time Entries and Dashboard sheets on every export. Re-running is always safe.
uv run --with openpyxl python3 ~/.claude/skills/time-entry/export.py WEEK_START
# WEEK_START is the Monday date, e.g. 2026-03-30. Omit for current week.
# kept for reference only — do not use
uv run --with openpyxl python3 << 'EOF_LEGACY'
import json, os
from datetime import datetime, timedelta
from collections import defaultdict
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
from openpyxl.utils import get_column_letter
# ── Config ──────────────────────────────────────────────────────────────
config_path = os.path.expanduser('~/.config/timetracker.json')
with open(config_path) as f:
config = json.load(f)
data_file = config['data_file']
xlsx_path = os.path.expanduser(config['spreadsheet_file'])
week_start_str = 'WEEK_START'
target_hours = 40
# ── Load entries ─────────────────────────────────────────────────────────
with open(data_file) as f:
all_entries = json.load(f)
week_entries = [e for e in all_entries if e.get('week_start') == week_start_str]
week_start = datetime.strptime(week_start_str, '%Y-%m-%d')
week_end = week_start + timedelta(days=6)
# ── Helpers ──────────────────────────────────────────────────────────────
BLUE_DARK = '1F3864'
BLUE_MID = '2E74B5'
BLUE_LIGHT = 'DEEAF1'
GREY_LIGHT = 'F5F5F5'
GREY_MID = 'D9D9D9'
GREEN = 'E2EFDA'
WHITE = 'FFFFFF'
BLACK = '000000'
def hdr_font(bold=True, color=WHITE, size=11):
return Font(bold=bold, color=color, size=size, name='Calibri')
def body_font(bold=False, color=BLACK, size=10):
return Font(bold=bold, color=color, size=size, name='Calibri')
def fill(hex_color):
return PatternFill('solid', fgColor=hex_color)
def center():
return Alignment(horizontal='center', vertical='center')
def left():
return Alignment(horizontal='left', vertical='center')
def thin_border(sides='all'):
s = Side(style='thin', color=GREY_MID)
n = Side(style=None)
t = s if 'all' in sides or 'top' in sides else n
b = s if 'all' in sides or 'bottom' in sides else n
l = s if 'all' in sides or 'left' in sides else n
r = s if 'all' in sides or 'right' in sides else n
return Border(top=t, bottom=b, left=l, right=r)
def set_row(ws, row, values, bg=None, font=None, align=None, border=None, height=None):
for col, val in enumerate(values, 1):
c = ws.cell(row=row, column=col, value=val)
if bg: c.fill = fill(bg)
if font: c.font = font
if align: c.alignment = align
if border: c.border = border
if height:
ws.row_dimensions[row].height = height
def progress_bar(hours, target, width=20):
filled = min(int((hours / target) * width), width)
return '█' * filled + '░' * (width - filled)
# ── Compute summary data ──────────────────────────────────────────────────
by_type = defaultdict(float)
by_project = defaultdict(float)
by_day = defaultdict(lambda: defaultdict(float))
for e in week_entries:
t = e.get('work_type', 'Billable')
by_type[t] += e['hours']
by_project[e['project']] += e['hours']
by_day[e['date']][t] += e['hours']
total_hours = sum(by_type.values())
billable = by_type.get('Billable', 0)
non_bill = by_type.get('Non-Billable', 0)
pto = by_type.get('PTO', 0)
utilization = billable / target_hours if target_hours else 0
days = [(week_start + timedelta(days=i)) for i in range(5)] # Mon–Fri
day_names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']
projects_sorted = sorted(by_project.items(), key=lambda x: -x[1])
# ── Open or create workbook ───────────────────────────────────────────────
os.makedirs(os.path.dirname(xlsx_path), exist_ok=True) if os.path.dirname(xlsx_path) else None
if os.path.exists(xlsx_path):
wb = openpyxl.load_workbook(xlsx_path)