/** * progress-tracker.ts * * Extension: Progress Tracker * Triggers: on_session_end * * Tracks weekly activity across skills and sessions. * Surfaces burnout prevention nudges and weekly summaries. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; import { getWorkspaceState } from "../workspace-detector"; import { type TuiTheme, badge, kv, miniBar, panelBlank, panelBottom, panelRow, panelTop, sectionDivider, skillBar, wordWrap, } from "./lib/tui"; // ─── Types ───────────────────────────────────────────────────────────────── export interface SessionRecord { date: string; // ISO date string durationMinutes: number; skillsUsed: string[]; topicsWorked: string[]; } export interface WeeklyStats { totalMinutes: number; sessionCount: number; skillBreakdown: Record; longestSession: number; streak: number; // days in a row with activity } // ─── Storage ─────────────────────────────────────────────────────────────── const TRACKER_DIR = path.join(os.homedir(), ".pisces"); const SESSIONS_FILE = path.join(TRACKER_DIR, "sessions.json"); function ensureTrackerDir(): void { if (!fs.existsSync(TRACKER_DIR)) { fs.mkdirSync(TRACKER_DIR, { recursive: true }); } } function loadSessions(): SessionRecord[] { try { if (!fs.existsSync(SESSIONS_FILE)) return []; const raw = fs.readFileSync(SESSIONS_FILE, "utf-8"); return JSON.parse(raw) as SessionRecord[]; } catch { return []; } } function saveSessions(sessions: SessionRecord[]): void { try { ensureTrackerDir(); const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - 90); const recent = sessions.filter((s) => new Date(s.date) > cutoff); fs.writeFileSync(SESSIONS_FILE, JSON.stringify(recent, null, 2)); } catch { // Fail silently — tracker is a nice-to-have, not critical } } // ─── Stats ───────────────────────────────────────────────────────────────── function getWeeklyStats(sessions: SessionRecord[]): WeeklyStats { const oneWeekAgo = new Date(); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); const weekSessions = sessions.filter( (s) => new Date(s.date) > oneWeekAgo ); const skillBreakdown: Record = {}; let totalMinutes = 0; let longestSession = 0; for (const session of weekSessions) { totalMinutes += session.durationMinutes; longestSession = Math.max(longestSession, session.durationMinutes); for (const skill of session.skillsUsed) { skillBreakdown[skill] = (skillBreakdown[skill] ?? 0) + 1; } } // Calculate streak let streak = 0; const sessionDays = new Set(sessions.map((s) => new Date(s.date).toDateString())); const checkDate = new Date(); while (sessionDays.has(checkDate.toDateString())) { streak++; checkDate.setDate(checkDate.getDate() - 1); } return { totalMinutes, sessionCount: weekSessions.length, skillBreakdown, longestSession, streak, }; } // ─── Nudges ───────────────────────────────────────────────────────────────── export function getBurnoutNudge( sessionDurationMinutes: number, stats: WeeklyStats ): string | null { if (sessionDurationMinutes >= 180) { return "🐠 You've been coding for 3+ hours. Seriously — take a 15-minute break. Your brain consolidates learning during rest, not during grinding."; } if (stats.totalMinutes > 40 * 60) { return "📊 You've put in 40+ hours of study this week. That's impressive, but rest is part of learning. Consistent rest is part of the process."; } if (stats.streak >= 7) { return `🔥 ${stats.streak}-day streak! Consistent daily practice beats marathon sessions. Keep this up.`; } return null; } // ─── Weekly Summary (plain-text fallback) ────────────────────────────────── export function buildWeeklySummary(stats: WeeklyStats): string { if (stats.sessionCount === 0) { return "📅 No activity recorded this week. When you're ready to start, just ask!"; } const hours = Math.floor(stats.totalMinutes / 60); const minutes = stats.totalMinutes % 60; const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; const topSkills = Object.entries(stats.skillBreakdown) .sort(([, a], [, b]) => b - a) .slice(0, 3) .map(([skill, count]) => ` - /${skill}: ${count} session${count > 1 ? "s" : ""}`) .join("\n"); return [ `📊 This Week's Summary`, ``, `⏱ Total study time: ${timeStr}`, `📅 Sessions: ${stats.sessionCount}`, `🔥 Current streak: ${stats.streak} day${stats.streak !== 1 ? "s" : ""}`, topSkills ? `\n🛠 Most used skills:\n${topSkills}` : "", ``, stats.totalMinutes < 5 * 60 ? `💡 Tip: Even 30 minutes a day compounds significantly over a semester.` : `✅ Solid week. Keep the consistency going.`, ] .filter(Boolean) .join("\n"); } // ─── TUI overlay constants ───────────────────────────────────────────────── const NUDGE_INNER_W = 52; const PROGRESS_INNER_W = 56; const DISMISS_HINT = "─── any key to dismiss ───"; // ─── Nudge panel component (burnout / break reminders) ───────────────────── function makeNudgeComponent( rawText: string, t: TuiTheme, done: () => void, ) { const w = NUDGE_INNER_W; const rows: Array<{ text: string; visualW: number }> = []; for (const raw of rawText.split("\n")) { if (raw === "") { rows.push({ text: "", visualW: 0 }); } else { const wrapped = raw.length <= w ? [raw] : wordWrap(raw, w); for (const line of wrapped) { rows.push({ text: line, visualW: line.length }); } } } return { render(): string[] { const out: string[] = []; out.push(panelTop(w, "accent", t)); out.push(panelBlank(w, "accent", t)); for (const row of rows) { if (row.visualW === 0) { out.push(panelBlank(w, "accent", t)); } else { out.push(panelRow(row.text, row.visualW, w, "accent", t)); } } out.push(panelBlank(w, "accent", t)); out.push(panelBottom(w, DISMISS_HINT, "accent", t)); return out; }, invalidate() {}, handleInput() { done(); }, }; } // ─── Progress dashboard component ────────────────────────────────────────── const STAT_LABEL_W = 13; const SKILL_NAME_W = 12; const SKILL_BAR_W = 10; function buildProgressRows( stats: WeeklyStats, innerW: number, t: TuiTheme, ): Array<{ text: string; visualW: number }> { const rows: Array<{ text: string; visualW: number }> = []; // Header badge const badgeText = badge("THIS WEEK", "accent", t); rows.push({ text: badgeText, visualW: "[ THIS WEEK ]".length }); rows.push({ text: "", visualW: 0 }); if (stats.sessionCount === 0) { const msg = "No activity recorded this week. Ready when you are!"; for (const line of wordWrap(msg, innerW)) { rows.push({ text: t.fg("muted", line), visualW: line.length }); } rows.push({ text: "", visualW: 0 }); rows.push({ text: t.fg("dim", "Start a session and run /progress to see your stats."), visualW: "Start a session and run /progress to see your stats.".length, }); return rows; } // Stat rows const hours = Math.floor(stats.totalMinutes / 60); const minutes = stats.totalMinutes % 60; const timeStr = hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`; rows.push(kv("Study time", timeStr, STAT_LABEL_W, "accent", t)); rows.push(kv("Sessions", String(stats.sessionCount), STAT_LABEL_W, "accent", t)); // Streak with mini bar (7-day window) const streakDays = Math.min(stats.streak, 7); const streakBar = miniBar(streakDays, 7, "accent", t); const streakLabel = `${stats.streak} day${stats.streak !== 1 ? "s" : ""}`; const streakVisualW = STAT_LABEL_W + 2 + streakLabel.length + 2 + 7; rows.push({ text: t.fg("muted", "Streak".padEnd(STAT_LABEL_W)) + " " + t.fg("accent", streakLabel) + " " + streakBar, visualW: streakVisualW, }); // Top skills section const topSkills = Object.entries(stats.skillBreakdown) .sort(([, a], [, b]) => b - a) .slice(0, 3); if (topSkills.length > 0) { rows.push({ text: "", visualW: 0 }); rows.push(sectionDivider("top skills", innerW, t)); rows.push({ text: "", visualW: 0 }); const maxCount = topSkills[0][1]; for (const [name, count] of topSkills) { rows.push(skillBar(name, count, maxCount, SKILL_NAME_W, SKILL_BAR_W, "accent", t)); } } rows.push({ text: "", visualW: 0 }); // Tip / encouragement line const tip = stats.totalMinutes < 5 * 60 ? "💡 Tip: Even 30 minutes a day compounds significantly over a semester." : "✅ Solid week. Keep the consistency going."; for (const line of wordWrap(tip, innerW)) { rows.push({ text: t.fg("muted", line), visualW: line.length }); } return rows; } function makeProgressComponent( stats: WeeklyStats, t: TuiTheme, done: () => void, ) { const w = PROGRESS_INNER_W; const rows = buildProgressRows(stats, w, t); return { render(): string[] { const out: string[] = []; out.push(panelTop(w, "accent", t)); out.push(panelBlank(w, "accent", t)); for (const row of rows) { if (row.visualW === 0) { out.push(panelBlank(w, "accent", t)); } else { out.push(panelRow(row.text, row.visualW, w, "accent", t)); } } out.push(panelBlank(w, "accent", t)); out.push(panelBottom(w, DISMISS_HINT, "accent", t)); return out; }, invalidate() {}, handleInput() { done(); }, }; } // ─── Overlay display ──────────────────────────────────────────────────────── type PiComponent = ReturnType; type ComponentFactory = (t: TuiTheme, done: () => void) => PiComponent; async function showOverlay( // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx: Record, factory: ComponentFactory, innerW: number, fallbackText: string, autoDismissMs: number, ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ui = ctx["ui"] as Record | undefined; if (typeof ui?.["custom"] !== "function") { ui?.["notify"]?.(fallbackText, "info"); return; } let dismiss: (() => void) | undefined; const p = ui["custom"]( // eslint-disable-next-line @typescript-eslint/no-explicit-any (_tui: unknown, theme: Record, _kb: unknown, done: () => void) => { dismiss = done; return factory(theme as unknown as TuiTheme, done); }, { overlay: true, overlayOptions: { anchor: "center", width: innerW + 4 }, }, ) as Promise; const timer = setTimeout(() => dismiss?.(), autoDismissMs); try { await p; } finally { clearTimeout(timer); } } async function showNudgeOverlay( // eslint-disable-next-line @typescript-eslint/no-explicit-any ctx: Record, text: string, autoDismissMs: number, ): Promise { await showOverlay( ctx, (t, done) => makeNudgeComponent(text, t, done), NUDGE_INNER_W, text, autoDismissMs, ); } // ─── Pi Extension Factory ────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { const sessionStart = Date.now(); const skillsUsed: string[] = []; pi.on("input", async (event) => { if (!getWorkspaceState().isActive) return { action: "continue" as const }; const match = event.text.match(/^\/skill:(\w+)/); if (match && !skillsUsed.includes(match[1])) { skillsUsed.push(match[1]); } return { action: "continue" as const }; }); pi.on("before_agent_start", async (_event, ctx) => { if (!getWorkspaceState().isActive) return; const elapsed = Math.round((Date.now() - sessionStart) / 60_000); if (elapsed > 0 && elapsed % 180 === 0) { await showNudgeOverlay( ctx as unknown as Record, `🐠 You've been at this for ${elapsed} minutes.\n\nTake a short break — your brain consolidates learning during rest, not during grinding.`, 9000, ); } }); pi.on("session_shutdown", async () => { if (!getWorkspaceState().isActive) return; const durationMinutes = Math.round((Date.now() - sessionStart) / 60_000); run({ sessionDurationMinutes: durationMinutes, skillsUsed }); }); pi.registerCommand("progress", { description: "Show this week's study summary and streak", handler: async (_args, ctx) => { if (!getWorkspaceState().isActive) { ctx.ui.notify("Activate a Pisces workspace first (/pisces --activate).", "warning"); return; } const sessions = loadSessions(); const stats = getWeeklyStats(sessions); await showOverlay( ctx as unknown as Record, (t, done) => makeProgressComponent(stats, t, done), PROGRESS_INNER_W, buildWeeklySummary(stats), 14000, ); }, }); } // ─── Pi Extension Entry Point ────────────────────────────────────────────── export function run(context: { sessionDurationMinutes: number; skillsUsed: string[]; topicsWorked?: string[]; }): { nudge: string | null; weeklySummary: string } { const sessions = loadSessions(); const record: SessionRecord = { date: new Date().toISOString(), durationMinutes: context.sessionDurationMinutes, skillsUsed: context.skillsUsed, topicsWorked: context.topicsWorked ?? [], }; sessions.push(record); saveSessions(sessions); const stats = getWeeklyStats(sessions); const nudge = getBurnoutNudge(context.sessionDurationMinutes, stats); const weeklySummary = buildWeeklySummary(stats); return { nudge, weeklySummary }; }