Initial commit — briefing CLI tool

Terminal dashboard for Nodie daily/weekly work summaries with:
- Color-coded task display with creation dates and "days ago" format
- NEW badge highlighting for tasks created today
- Grouped sections: New Today, Needs Work, Waiting, Done
- Interactive mode: type a task number to see full details
- Calendar rendering with NOW/DONE/NEXT markers
- Git commit summary per repo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 13:00:08 +02:00
commit 9189896d4a

531
briefing Executable file
View File

@@ -0,0 +1,531 @@
#!/usr/bin/env python3
"""
Nodie Briefing — terminal display with interactive mode
Usage: briefing [7|30] [client]
briefing — 7-day daily briefing
briefing 30 — 30-day weekly review
briefing 7 OHS — 7-day, OHS only
"""
import json, sys, subprocess, re, readline
from datetime import datetime, timezone, timedelta
API_KEY = "b666180ef00fffd23b9a606a4bd8deddea274d2e69fab975583d3d966fbfafed"
BASE_URL = "https://teacup.nodie.co.za/api/v1"
# ANSI
BOLD = "\033[1m"
DIM = "\033[2m"
ITALIC = "\033[3m"
UNDER = "\033[4m"
RESET = "\033[0m"
WHITE = "\033[97m"
GREY = "\033[90m"
CYAN = "\033[36m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
MAGENTA = "\033[35m"
BG_YELLOW = "\033[43m"
BG_GREEN = "\033[42m"
BLACK = "\033[30m"
SAST = timezone(timedelta(hours=2))
# Priority colors
PRI_COLOR = {
'critical': RED,
'high': RED,
'medium': YELLOW,
'low': GREY,
None: GREY,
'': GREY,
}
# Status colors
STATUS_COLOR = {
'in_progress': GREEN,
'todo': CYAN,
'hold_to_discuss': MAGENTA,
'waitingfortesting': YELLOW,
'willdemo': YELLOW,
'done': DIM,
'completed': DIM,
}
def days_ago(created_at_str):
"""Return human-friendly age string."""
if not created_at_str:
return ""
try:
created = datetime.strptime(created_at_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=SAST)
now = datetime.now(SAST)
delta = now - created
if delta.days == 0:
return "today"
elif delta.days == 1:
return "1d ago"
elif delta.days < 7:
return f"{delta.days}d ago"
elif delta.days < 30:
weeks = delta.days // 7
return f"{weeks}w ago"
else:
months = delta.days // 30
return f"{months}mo ago"
except (ValueError, TypeError):
return ""
def is_new(created_at_str):
"""True if created today."""
if not created_at_str:
return False
try:
created = datetime.strptime(created_at_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=SAST)
now = datetime.now(SAST)
return created.date() == now.date()
except (ValueError, TypeError):
return False
def format_priority(pri):
color = PRI_COLOR.get(pri, GREY)
return f"{color}{pri or 'unset'}{RESET}"
def format_status(status):
color = STATUS_COLOR.get(status, GREY)
return f"{color}{status or 'unset'}{RESET}"
def new_badge():
return f" {BG_YELLOW}{BLACK} NEW {RESET}"
def print_task(task, indent=" "):
"""Print a single task line with metadata."""
nid = task['nodesid']
name = task['node_name']
status = task.get('status')
priority = task.get('priority')
created = task.get('created_at', '')
server = task.get('server')
assigned = task.get('on_behalf_of')
age = days_ago(created)
is_today = is_new(created)
# Task number + name
badge = new_badge() if is_today else ""
line = f"{indent}{YELLOW}#{nid}{RESET} {BOLD}{WHITE}{name}{RESET}{badge}"
print(line)
# Meta line
parts = [format_status(status), format_priority(priority)]
if age:
age_color = GREEN if is_today else GREY
parts.append(f"{age_color}{age}{RESET}")
if server:
parts.append(f"{CYAN}{server}{RESET}")
if assigned:
# Shorten email format
short = assigned.split(' (')[0] if ' (' in assigned else assigned
parts.append(f"{DIM}For: {short}{RESET}")
print(f"{indent} {' | '.join(parts)}")
# Children summary
cs = task.get('children_summary')
if cs and cs.get('total', 0) > 0:
child_parts = []
for key in ['in_progress', 'todo', 'waitingfortesting', 'done', 'completed']:
if cs.get(key, 0) > 0:
child_parts.append(f"{cs[key]} {key}")
if child_parts:
print(f"{indent} {GREY}Subtasks: {', '.join(child_parts)}{RESET}")
# Latest note preview
note = task.get('latest_note')
if note:
text = note.get('content', '')
if len(text) > 100:
text = text[:100] + "..."
print(f"{indent} {DIM}{ITALIC}{text}{RESET}")
def render_calendar(cal_data):
"""Render calendar section from raw data."""
if not cal_data:
return
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
print(f"{BOLD}{CYAN}Calendar{RESET}")
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
now = datetime.now(SAST)
for date_str, events in cal_data.items():
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
if dt.date() == now.date():
label = f"{dt.strftime('%a %d %b')} (today)"
elif dt.date() == (now + timedelta(days=1)).date():
label = f"{dt.strftime('%a %d %b')} (tomorrow)"
else:
label = dt.strftime('%a %d %b')
except ValueError:
label = date_str
print(f"\n {BOLD}{WHITE}{label}{RESET}")
for ev in events:
start = ev.get('start_datetime', '')
end = ev.get('end_datetime', '')
title = ev.get('title', '')
# Format times
try:
s = datetime.strptime(start, "%Y-%m-%d %H:%M:%S")
e = datetime.strptime(end, "%Y-%m-%d %H:%M:%S")
time_str = f"{s.strftime('%H:%M')}{e.strftime('%H:%M')}"
# Status marker
if s.replace(tzinfo=SAST) < now < e.replace(tzinfo=SAST):
marker = f" {BG_GREEN}{BLACK} NOW {RESET}"
elif e.replace(tzinfo=SAST) < now:
marker = f" {GREY}[DONE]{RESET}"
elif s.replace(tzinfo=SAST) > now and (s.replace(tzinfo=SAST) - now).seconds < 3600:
marker = f" {CYAN}[NEXT]{RESET}"
else:
marker = ""
except ValueError:
time_str = start
marker = ""
print(f" {DIM}{time_str}{RESET} {title}{marker}")
def render_tasks(tasks):
"""Render tasks grouped by category, with NEW section first."""
if not tasks:
return
# Separate new tasks
new_tasks = [t for t in tasks if t.get('category') == 'actionable' and is_new(t.get('created_at', ''))]
actionable = [t for t in tasks if t.get('category') == 'actionable' and not is_new(t.get('created_at', ''))]
waiting = [t for t in tasks if t.get('category') == 'waiting']
done = [t for t in tasks if t.get('category') == 'done']
# New today
if new_tasks:
print(f"\n{BOLD}{RED}{'─' * 60}{RESET}")
print(f"{BOLD}{RED}New Today ({len(new_tasks)}){RESET}")
print(f"{BOLD}{RED}{'─' * 60}{RESET}")
by_client = group_by_client(new_tasks)
for client, projects in by_client.items():
print(f"\n {BOLD}{WHITE}{client}{RESET}")
for project, ptasks in projects.items():
if project:
print(f" {BOLD}{GREY}{project}{RESET}")
for t in ptasks:
print_task(t, " ")
# Actionable
if actionable:
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
print(f"{BOLD}{CYAN}Needs Work ({len(actionable)}){RESET}")
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
by_client = group_by_client(actionable)
for client, projects in by_client.items():
print(f"\n {BOLD}{WHITE}{client}{RESET}")
for project, ptasks in projects.items():
if project:
print(f" {BOLD}{GREY}{project}{RESET}")
for t in ptasks:
print_task(t, " ")
# Waiting
if waiting:
print(f"\n{BOLD}{YELLOW}{'─' * 60}{RESET}")
print(f"{BOLD}{YELLOW}Waiting on Others ({len(waiting)}){RESET}")
print(f"{BOLD}{YELLOW}{'─' * 60}{RESET}")
by_client = group_by_client(waiting)
for client, projects in by_client.items():
print(f"\n {BOLD}{WHITE}{client}{RESET}")
for project, ptasks in projects.items():
if project:
print(f" {BOLD}{GREY}{project}{RESET}")
for t in ptasks:
print_task(t, " ")
# Done
if done:
print(f"\n{BOLD}{GREEN}{'─' * 60}{RESET}")
print(f"{BOLD}{GREEN}Recently Completed ({len(done)}){RESET}")
print(f"{BOLD}{GREEN}{'─' * 60}{RESET}")
by_client = group_by_client(done)
for client, projects in by_client.items():
print(f"\n {BOLD}{WHITE}{client}{RESET}")
for project, ptasks in projects.items():
if project:
print(f" {BOLD}{GREY}{project}{RESET}")
for t in ptasks:
print_task(t, " ")
def group_by_client(tasks):
"""Group tasks by client > project, preserving order."""
result = {}
for t in tasks:
client = t.get('client') or 'Unknown'
project = t.get('project') or ''
if client not in result:
result[client] = {}
if project not in result[client]:
result[client][project] = []
result[client][project].append(t)
return result
def render_commits(commits):
"""Render git commit summary."""
if not commits:
return
total = sum(len(c) for c in commits.values())
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
print(f"{BOLD}{CYAN}Commits ({total}){RESET}")
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
for repo, repo_commits in commits.items():
print(f"\n {BOLD}{WHITE}{repo}{RESET} {GREY}({len(repo_commits)}){RESET}")
for c in repo_commits[:5]: # Show max 5 per repo
sha = c.get('sha', '')[:7]
date = c.get('date', '')[:10]
msg = c.get('message', '').split('\n')[0]
if len(msg) > 70:
msg = msg[:70] + "..."
print(f" {GREY}{sha} {date}{RESET} {msg}")
if len(repo_commits) > 5:
print(f" {DIM}... +{len(repo_commits) - 5} more{RESET}")
def render_summary(summary, range_days):
"""Render the summary header."""
now = datetime.now(SAST)
print(f"\n{BOLD}{WHITE}{now.strftime('%a %d %b, %H:%M')}{RESET}")
a = summary.get('actionable', 0)
w = summary.get('waiting', 0)
b = summary.get('blocked', 0)
d = summary.get('done', 0)
m = summary.get('meetings_today', 0)
parts = []
if a: parts.append(f"{CYAN}{a} actionable{RESET}")
if w: parts.append(f"{YELLOW}{w} waiting{RESET}")
if b: parts.append(f"{RED}{b} blocked{RESET}")
if d: parts.append(f"{GREEN}{d} done{RESET}")
print(f" {' | '.join(parts)} — {range_days}-day window")
if m:
print(f" {MAGENTA}{m} meeting{'s' if m != 1 else ''} today{RESET}")
def fetch_task_detail(nid):
"""Fetch full task detail for interactive mode."""
result = subprocess.run(
["curl", "-s", "-H", f"Authorization: Bearer {API_KEY}",
f"{BASE_URL}/nodes/{nid}"],
capture_output=True, text=True
)
try:
data = json.loads(result.stdout)
if data.get('success'):
return data['data']
except (json.JSONDecodeError, KeyError):
pass
return None
def show_task_detail(nid):
"""Show full task detail in interactive mode."""
print(f"\n {DIM}Fetching #{nid}...{RESET}")
data = fetch_task_detail(nid)
if not data:
print(f" {RED}Could not fetch task #{nid}{RESET}")
return
node = data.get('node', {})
details = data.get('details', {})
bc = data.get('breadcrumb', {})
children = data.get('children', [])
# Header
print(f"\n {BOLD}{WHITE}#{node.get('nodesid')} — {node.get('node_name')}{RESET}")
print(f" {GREY}{'─' * 50}{RESET}")
# Breadcrumb
bc_parts = []
if bc.get('client'):
bc_parts.append(bc['client'].get('node_name', ''))
if bc.get('project'):
bc_parts.append(bc['project'].get('node_name', ''))
if bc.get('category'):
bc_parts.append(bc['category'].get('node_name', ''))
if bc_parts:
print(f" {DIM}{' > '.join(bc_parts)}{RESET}")
# Dates
created = node.get('created_at', '')
updated = node.get('updated_at', '')
created_by = node.get('created_by', '')
age = days_ago(created)
print(f" {DIM}Created: {created} ({age}){' by ' + created_by if created_by else ''}{RESET}")
if updated != created:
print(f" {DIM}Updated: {updated} ({days_ago(updated)}){RESET}")
# Details
if isinstance(details, dict):
status = details.get('status') or details.get('taskStatus') or details.get('subtaskStatus')
priority = details.get('priority') or details.get('taskPriority') or details.get('subtaskPriority')
server = details.get('server')
assigned = details.get('on_behalf_of')
completion = details.get('completion') or details.get('taskCompletion') or details.get('subtaskCompletion')
meta = []
if status: meta.append(f"Status: {format_status(status)}")
if priority: meta.append(f"Priority: {format_priority(priority)}")
if completion and completion != '0': meta.append(f"Completion: {completion}%")
if server: meta.append(f"Server: {CYAN}{server}{RESET}")
if assigned: meta.append(f"For: {assigned}")
for m in meta:
print(f" {m}")
# Description
desc = details.get('description')
if desc:
print(f"\n {WHITE}{desc}{RESET}")
# Note content (from ticket)
note = details.get('note_content')
if note:
# Strip HTML
clean = re.sub(r'<[^>]+>', ' ', note)
clean = re.sub(r'\s+', ' ', clean).strip()
if len(clean) > 500:
clean = clean[:500] + "..."
print(f"\n {DIM}{clean}{RESET}")
# Children
if children:
subtasks = [c for c in children if c.get('node_typesid') in (4, 5)]
notes = [c for c in children if c.get('node_typesid') == 6]
if subtasks:
print(f"\n {BOLD}Subtasks ({len(subtasks)}):{RESET}")
for st in subtasks:
st_details = st.get('details', {})
st_status = ''
if isinstance(st_details, dict):
st_status = st_details.get('status') or st_details.get('subtaskStatus') or ''
print(f" {YELLOW}#{st['nodesid']}{RESET} {st['node_name']} {GREY}[{st_status}]{RESET}")
if notes:
print(f"\n {BOLD}Notes ({len(notes)}):{RESET}")
for n in notes[-3:]: # Show last 3 notes
n_details = n.get('details', {})
content = ''
if isinstance(n_details, dict):
content = n_details.get('note_content') or n_details.get('description') or ''
content = re.sub(r'<[^>]+>', ' ', content)
content = re.sub(r'\s+', ' ', content).strip()
if len(content) > 200:
content = content[:200] + "..."
n_age = days_ago(n.get('created_at', ''))
print(f" {GREY}{n.get('created_at', '')[:10]} ({n_age}){RESET}")
print(f" {DIM}{content}{RESET}")
print(f"\n {DIM}URL: https://teacup.nodie.co.za/?sn={node.get('nodesid')}{RESET}")
def interactive_mode(tasks):
"""Interactive prompt — type a task number to see details."""
# Build lookup
task_ids = set()
for t in tasks:
task_ids.add(str(t['nodesid']))
print(f"\n{BOLD}{CYAN}{'─' * 60}{RESET}")
print(f"{DIM}Type a task number for details, or 'q' to quit{RESET}")
print(f"{BOLD}{CYAN}{'─' * 60}{RESET}")
while True:
try:
inp = input(f"\n{BOLD}{CYAN}#{RESET} ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not inp or inp.lower() in ('q', 'quit', 'exit'):
break
# Strip # prefix if present
inp = inp.lstrip('#')
if inp.isdigit():
show_task_detail(int(inp))
else:
print(f" {DIM}Enter a task number (e.g. 3047) or 'q' to quit{RESET}")
def main():
range_days = "7"
client = None
args = sys.argv[1:]
if args and args[0].isdigit():
range_days = args.pop(0)
if args:
client = args[0]
url = f"{BASE_URL}/briefing?range={range_days}"
if client:
url += f"&client={client}"
result = subprocess.run(
["curl", "-s", "-H", f"Authorization: Bearer {API_KEY}", url],
capture_output=True, text=True
)
if not result.stdout.strip():
print(f"{RED}Empty response from API{RESET}")
sys.exit(1)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
print(f"{RED}Invalid JSON response{RESET}")
print(result.stdout[:500])
sys.exit(1)
if not data.get("success"):
print(f"{RED}API error: {data.get('message', 'Unknown')}{RESET}")
sys.exit(1)
raw = data["data"]
# Render from raw JSON data
render_summary(raw.get('summary', {}), int(range_days))
render_calendar(raw.get('calendar', {}))
render_tasks(raw.get('tasks', []))
render_commits(raw.get('git_commits', {}))
# Interactive mode
tasks = raw.get('tasks', [])
if tasks and sys.stdin.isatty():
interactive_mode(tasks)
if __name__ == "__main__":
main()