commit_your_day.py
python · 1120 lines
1#!/usr/bin/env python32"""Git-style journaling CLI that turns daily reflections into structured life commits."""34from __future__ import annotations56import argparse7import calendar8import contextlib9import json10import logging11import os12import random13import shutil14import sys15import tempfile16import uuid17from collections import Counter18from dataclasses import dataclass, field19from datetime import date, datetime, timedelta, timezone20from pathlib import Path21from typing import TypedDict2223# ---------------------------------------------------------------------------24# Constants25# ---------------------------------------------------------------------------2627STORAGE_DIR: Path = Path.home() / ".commit_your_day"28STORAGE_FILE: Path = STORAGE_DIR / "life_commits.json"29SCHEMA_VERSION: str = "1.0"30JSON_INDENT: int = 231DATA_ENCODING: str = "utf-8"32BACKUP_SUFFIX: str = ".bak"33MAX_BACKUPS: int = 33435MOOD_MIN: int = -236MOOD_MAX: int = 237MOOD_DEFAULT: int = 038ENERGY_MIN: int = 139ENERGY_MAX: int = 540ENERGY_DEFAULT: int = 34142LOG_DEFAULT_COUNT: int = 1043REFLECTION_CHANCE: float = 0.344COMMIT_INDENT: str = " "45SHORT_ID_LENGTH: int = 746TOP_THEMES_COUNT: int = 547WEEK_DAYS: int = 748STREAK_MIN_DISPLAY: int = 24950HEATMAP_WEEKS: int = 1551HEATMAP_INTENSITY_LEVELS: int = 452HEATMAP_DAY_LABELS: tuple[str, ...] = ("Mon", "", "Wed", "", "Fri", "", "Sun")53HEATMAP_CHARS_COLOR: tuple[str, ...] = ("\u2591", "\u2592", "\u2593", "\u2588")54HEATMAP_CHARS_PLAIN: tuple[str, ...] = (".", "o", "O", "#")55HEATMAP_LABEL_WIDTH: int = 456TAG_MOOD_MIN_OCCURRENCES: int = 25758VALID_COMMIT_TYPES: tuple[str, ...] = (59 "feat",60 "fix",61 "refactor",62 "chore",63 "docs",64 "style",65 "test",66)6768TYPE_KEYWORDS: dict[str, str] = {69 "fixed": "fix",70 "repaired": "fix",71 "resolved": "fix",72 "debugged": "fix",73 "patched": "fix",74 "learned": "docs",75 "studied": "docs",76 "researched": "docs",77 "documented": "docs",78 "noted": "docs",79 "tried": "feat",80 "built": "feat",81 "created": "feat",82 "shipped": "feat",83 "launched": "feat",84 "finished": "feat",85 "completed": "feat",86 "started": "feat",87 "made": "feat",88 "added": "feat",89 "improved": "refactor",90 "optimized": "refactor",91 "refactored": "refactor",92 "restructured": "refactor",93 "simplified": "refactor",94 "cleaned": "chore",95 "organized": "chore",96 "maintained": "chore",97 "updated": "chore",98 "styled": "style",99 "designed": "style",100 "formatted": "style",101 "tested": "test",102 "verified": "test",103 "validated": "test",104}105106REFLECTION_LINES: tuple[str, ...] = (107 "Small commits still move the project forward.",108 "Consistency compounds.",109 "You showed up today. That matters.",110 "Progress isn't always visible, but it's real.",111 "Every commit is a choice to keep going.",112 "The changelog of your life is being written.",113 "Ship it. Reflect. Repeat.",114)115116# ---------------------------------------------------------------------------117# ANSI formatting118# ---------------------------------------------------------------------------119120_SUPPORTS_COLOR: bool = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()121122_ANSI_BOLD: str = "\033[1m"123_ANSI_DIM: str = "\033[2m"124_ANSI_GREEN: str = "\033[32m"125_ANSI_YELLOW: str = "\033[33m"126_ANSI_RESET: str = "\033[0m"127128129def _bold(text: str) -> str:130 if not _SUPPORTS_COLOR:131 return text132 return f"{_ANSI_BOLD}{text}{_ANSI_RESET}"133134135def _dim(text: str) -> str:136 if not _SUPPORTS_COLOR:137 return text138 return f"{_ANSI_DIM}{text}{_ANSI_RESET}"139140141def _green(text: str) -> str:142 if not _SUPPORTS_COLOR:143 return text144 return f"{_ANSI_GREEN}{text}{_ANSI_RESET}"145146147def _yellow(text: str) -> str:148 if not _SUPPORTS_COLOR:149 return text150 return f"{_ANSI_YELLOW}{text}{_ANSI_RESET}"151152153# ---------------------------------------------------------------------------154# Output helpers (T20-compliant: no print())155# ---------------------------------------------------------------------------156157158def _out(text: str = "") -> None:159 sys.stdout.write(text + "\n")160161162def _err(text: str) -> None:163 sys.stderr.write(text + "\n")164165166# ---------------------------------------------------------------------------167# Logging168# ---------------------------------------------------------------------------169170logger: logging.Logger = logging.getLogger(__name__)171172173# ---------------------------------------------------------------------------174# Domain models175# ---------------------------------------------------------------------------176177178@dataclass(frozen=True)179class LifeCommit:180 """A single structured life commit entry."""181182 commit_id: str183 timestamp: str184 commit_date: str185 commit_type: str186 message: str187 scope: str = "life"188 details: str = ""189 tags: tuple[str, ...] = ()190 mood_score: int = MOOD_DEFAULT191 energy_score: int = ENERGY_DEFAULT192193 def to_dict(self) -> dict[str, object]:194 """Serialize to JSON-compatible dictionary."""195 return {196 "id": self.commit_id,197 "timestamp": self.timestamp,198 "date": self.commit_date,199 "type": self.commit_type,200 "scope": self.scope,201 "message": self.message,202 "details": self.details,203 "tags": list(self.tags),204 "mood_score": self.mood_score,205 "energy_score": self.energy_score,206 }207208 @staticmethod209 def from_dict(data: dict[str, object]) -> LifeCommit:210 """Deserialize from a JSON-parsed dictionary."""211 tags_raw = data.get("tags", [])212 tags: tuple[str, ...] = ()213 if isinstance(tags_raw, list):214 tags = tuple(str(t) for t in tags_raw)215216 mood_raw = data.get("mood_score", MOOD_DEFAULT)217 mood = int(mood_raw) if isinstance(mood_raw, (int, float)) else MOOD_DEFAULT218219 energy_raw = data.get("energy_score", ENERGY_DEFAULT)220 energy = (221 int(energy_raw) if isinstance(energy_raw, (int, float)) else ENERGY_DEFAULT222 )223224 return LifeCommit(225 commit_id=str(data.get("id", "")),226 timestamp=str(data.get("timestamp", "")),227 commit_date=str(data.get("date", "")),228 commit_type=str(data.get("type", "feat")),229 scope=str(data.get("scope", "life")),230 message=str(data.get("message", "")),231 details=str(data.get("details", "")),232 tags=tags,233 mood_score=mood,234 energy_score=energy,235 )236237 def short_id(self) -> str:238 """Return first 7 characters of the commit ID."""239 return self.commit_id[:SHORT_ID_LENGTH]240241 def format_type_scope(self) -> str:242 """Format as 'type(scope)' string."""243 if self.scope:244 return f"{self.commit_type}({self.scope})"245 return self.commit_type246247 def format_mood(self) -> str:248 """Format mood score with explicit sign."""249 if self.mood_score > 0:250 return f"+{self.mood_score}"251 return str(self.mood_score)252253254@dataclass255class CommitStore:256 """Container for the full commit history."""257258 version: str = SCHEMA_VERSION259 created_at: str = ""260 commits: list[LifeCommit] = field(default_factory=list)261262 def to_dict(self) -> dict[str, object]:263 """Serialize to JSON-compatible dictionary."""264 return {265 "version": self.version,266 "created_at": self.created_at,267 "commits": [c.to_dict() for c in self.commits],268 }269270 @staticmethod271 def from_dict(data: dict[str, object]) -> CommitStore:272 """Deserialize from a JSON-parsed dictionary."""273 commits_raw = data.get("commits", [])274 commits: list[LifeCommit] = (275 [276 LifeCommit.from_dict(entry)277 for entry in commits_raw278 if isinstance(entry, dict)279 ]280 if isinstance(commits_raw, list)281 else []282 )283 return CommitStore(284 version=str(data.get("version", SCHEMA_VERSION)),285 created_at=str(data.get("created_at", "")),286 commits=commits,287 )288289290def _new_store() -> CommitStore:291 return CommitStore(292 version=SCHEMA_VERSION,293 created_at=datetime.now(tz=timezone.utc).isoformat(),294 )295296297# ---------------------------------------------------------------------------298# Persistence layer299# ---------------------------------------------------------------------------300301302def _backup_path(path: Path, generation: int) -> Path:303 return path.parent / f"{path.name}{BACKUP_SUFFIX}{generation}"304305306def _ensure_storage_dir() -> None:307 STORAGE_DIR.mkdir(parents=True, exist_ok=True)308309310def _write_atomic_json(path: Path, data: dict[str, object]) -> None:311 """Write JSON data atomically using tempfile + fsync + replace."""312 serialized = json.dumps(data, indent=JSON_INDENT, ensure_ascii=False)313 fd, tmp_path_str = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")314 tmp_path = Path(tmp_path_str)315 try:316 with os.fdopen(fd, "w", encoding=DATA_ENCODING) as f:317 f.write(serialized)318 f.flush()319 os.fsync(f.fileno())320 tmp_path.replace(path)321 except BaseException:322 with contextlib.suppress(OSError):323 tmp_path.unlink(missing_ok=True)324 raise325326327def _rotate_backups(path: Path) -> None:328 for gen in range(MAX_BACKUPS - 1, 0, -1):329 src = _backup_path(path, gen)330 dst = _backup_path(path, gen + 1)331 if src.exists():332 shutil.move(str(src), dst)333 if path.exists():334 shutil.copy2(path, _backup_path(path, 1))335336337def _save_store(path: Path, store: CommitStore) -> None:338 """Persist the commit store with backup rotation and atomic write."""339 _ensure_storage_dir()340 _rotate_backups(path)341 _write_atomic_json(path, store.to_dict())342343344def _load_store(path: Path) -> CommitStore:345 """Load the commit store, falling back through backups on corruption."""346 candidates = [path, *[_backup_path(path, g) for g in range(1, MAX_BACKUPS + 1)]]347348 for candidate in candidates:349 if not candidate.exists():350 continue351 try:352 raw = candidate.read_text(encoding=DATA_ENCODING)353 data = json.loads(raw)354 if not isinstance(data, dict):355 logger.warning("Invalid root type in %s", candidate)356 continue357 store = CommitStore.from_dict(data)358 if candidate != path:359 logger.warning("Recovered from backup: %s", candidate)360 _write_atomic_json(path, store.to_dict())361 return store362 except (json.JSONDecodeError, OSError, KeyError, TypeError):363 logger.warning("Corrupt or unreadable: %s", candidate)364 if candidate == path:365 corrupted = path.parent / f"{path.name}.corrupted"366 with contextlib.suppress(OSError):367 shutil.copy2(candidate, corrupted)368369 return _new_store()370371372# ---------------------------------------------------------------------------373# Commit type detection374# ---------------------------------------------------------------------------375376377def _detect_commit_type(message: str) -> str:378 """Infer commit type from keywords in the message."""379 words = message.lower().split()380 for word in words:381 cleaned = word.strip(".,!?;:")382 if cleaned in TYPE_KEYWORDS:383 return TYPE_KEYWORDS[cleaned]384 return "feat"385386387# ---------------------------------------------------------------------------388# Date and time helpers389# ---------------------------------------------------------------------------390391392def _today() -> str:393 return date.today().isoformat()394395396def _now_utc_iso() -> str:397 return datetime.now(tz=timezone.utc).isoformat()398399400def _format_display_date(date_str: str) -> str:401 if date_str == _today():402 return "Today"403 try:404 dt = datetime.strptime(date_str, "%Y-%m-%d")405 return dt.strftime("%b %d")406 except ValueError:407 return date_str408409410def _format_long_date(date_str: str) -> str:411 if date_str == _today():412 return "Today"413 try:414 dt = datetime.strptime(date_str, "%Y-%m-%d")415 return dt.strftime("%b %d, %Y")416 except ValueError:417 return date_str418419420def _relative_time(date_str: str) -> str:421 """Compute human-readable relative time from a date string."""422 try:423 commit_dt = datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)424 except ValueError:425 return date_str426427 days = (datetime.now(tz=timezone.utc) - commit_dt).days428429 if days <= 0:430 return "today"431 if days == 1:432 return "yesterday"433 if days < WEEK_DAYS:434 return f"{days} days ago"435 weeks = days // WEEK_DAYS436 if days < 30:437 return f"{weeks} week{'s' if weeks != 1 else ''} ago"438 months = days // 30439 if days < 365:440 return f"{months} month{'s' if months != 1 else ''} ago"441 years = days // 365442 return f"{years} year{'s' if years != 1 else ''} ago"443444445# ---------------------------------------------------------------------------446# Streak computation447# ---------------------------------------------------------------------------448449450def _compute_streak(commits: list[LifeCommit]) -> int:451 """Compute the current consecutive-day commit streak."""452 if not commits:453 return 0454455 unique_dates = sorted({c.commit_date for c in commits}, reverse=True)456 today_str = _today()457 yesterday_str = (date.today() - timedelta(days=1)).isoformat()458459 if unique_dates[0] not in (today_str, yesterday_str):460 return 0461462 streak = 1463 for i in range(1, len(unique_dates)):464 try:465 prev = datetime.strptime(unique_dates[i - 1], "%Y-%m-%d").date()466 curr = datetime.strptime(unique_dates[i], "%Y-%m-%d").date()467 except ValueError:468 break469 if (prev - curr).days == 1:470 streak += 1471 else:472 break473474 return streak475476477# ---------------------------------------------------------------------------478# Summary computation479# ---------------------------------------------------------------------------480481482def _display_summary(commits: list[LifeCommit], period_label: str) -> None:483 """Compute and display a summary of commits for a given period."""484 if not commits:485 _out(f"\n No commits in the {period_label} period.\n")486 return487488 total = len(commits)489 all_tags: list[str] = []490 mood_sum = 0491 energy_sum = 0492 for commit in commits:493 all_tags.extend(commit.tags)494 mood_sum += commit.mood_score495 energy_sum += commit.energy_score496497 tag_counts = Counter(all_tags)498 top_themes = (499 [tag for tag, _ in tag_counts.most_common(TOP_THEMES_COUNT)]500 if tag_counts501 else []502 )503 mood_avg = mood_sum / total504 energy_avg = energy_sum / total505506 _out()507 _out(f" {_bold(f'{period_label.title()} Life Summary')}")508 _out()509 _out(f" Commits: {total}")510 if top_themes:511 _out(f" Top themes: {', '.join(top_themes)}")512 _out(f" Mood average: {mood_avg:+.1f}")513 _out(f" Energy average: {energy_avg:.1f}")514 _out()515 if random.random() < REFLECTION_CHANCE: # noqa: S311516 _out(f" {_dim(random.choice(REFLECTION_LINES))}") # noqa: S311517 _out()518519520# ---------------------------------------------------------------------------521# Stats computation522# ---------------------------------------------------------------------------523524525class _WeekStats(TypedDict):526 count: int527 mood_avg: float528 energy_avg: float529530531def _commits_by_date(commits: list[LifeCommit]) -> dict[str, list[LifeCommit]]:532 """Group commits by their ISO date string."""533 by_date: dict[str, list[LifeCommit]] = {}534 for commit in commits:535 by_date.setdefault(commit.commit_date, []).append(commit)536 return by_date537538539def _compute_tag_counts(commits: list[LifeCommit]) -> Counter[str]:540 """Aggregate tag usage counts across all commits."""541 all_tags: list[str] = []542 for commit in commits:543 all_tags.extend(commit.tags)544 return Counter(all_tags)545546547def _compute_type_distribution(commits: list[LifeCommit]) -> list[tuple[str, int]]:548 """Return commit types sorted by frequency descending."""549 type_counts: Counter[str] = Counter(c.commit_type for c in commits)550 return type_counts.most_common()551552553def _week_stats(commits: list[LifeCommit]) -> _WeekStats:554 """Compute aggregate stats for a list of commits."""555 if not commits:556 return {"count": 0, "mood_avg": 0.0, "energy_avg": 0.0}557 count = len(commits)558 mood_avg = sum(c.mood_score for c in commits) / count559 energy_avg = sum(c.energy_score for c in commits) / count560 return {"count": count, "mood_avg": mood_avg, "energy_avg": energy_avg}561562563def _compute_weekly_trend(564 commits: list[LifeCommit],565) -> tuple[_WeekStats, _WeekStats]:566 """Compare this week vs last week on commits, mood, and energy."""567 today = date.today()568 this_monday = today - timedelta(days=today.weekday())569 last_monday = this_monday - timedelta(days=WEEK_DAYS)570571 this_week_iso = this_monday.isoformat()572 last_week_iso = last_monday.isoformat()573 today_iso = today.isoformat()574575 this_week = [c for c in commits if this_week_iso <= c.commit_date <= today_iso]576 last_week = [c for c in commits if last_week_iso <= c.commit_date < this_week_iso]577578 return _week_stats(this_week), _week_stats(last_week)579580581def _compute_tag_mood_correlation(582 commits: list[LifeCommit],583 top_n: int = TOP_THEMES_COUNT,584) -> list[tuple[str, float, int]]:585 """For top tags, compute average mood when that tag is present."""586 tag_counts = _compute_tag_counts(commits)587 top_tags = [tag for tag, _ in tag_counts.most_common(top_n)]588589 results: list[tuple[str, float, int]] = []590 for tag in top_tags:591 tagged = [c for c in commits if tag in c.tags]592 count = len(tagged)593 if count < TAG_MOOD_MIN_OCCURRENCES:594 continue595 avg_mood = sum(c.mood_score for c in tagged) / count596 results.append((tag, avg_mood, count))597598 return results599600601# ---------------------------------------------------------------------------602# Activity heatmap603# ---------------------------------------------------------------------------604605606def _build_heatmap_grid(607 commits: list[LifeCommit],608) -> tuple[list[list[int]], list[str], date]:609 """Build a 7-row x HEATMAP_WEEKS-column grid of daily commit counts.610611 Returns (grid, month_labels, start_date).612 """613 today = date.today()614 current_monday = today - timedelta(days=today.weekday())615 start_monday = current_monday - timedelta(weeks=HEATMAP_WEEKS - 1)616617 date_counts: dict[str, int] = {618 k: len(v) for k, v in _commits_by_date(commits).items()619 }620621 grid: list[list[int]] = [[0] * HEATMAP_WEEKS for _ in range(WEEK_DAYS)]622 month_labels: list[str] = [""] * HEATMAP_WEEKS623624 prev_month: int = -1625 for week_idx in range(HEATMAP_WEEKS):626 week_monday = start_monday + timedelta(weeks=week_idx)627628 if week_monday.month != prev_month:629 month_labels[week_idx] = calendar.month_abbr[week_monday.month]630 prev_month = week_monday.month631632 for day_offset in range(WEEK_DAYS):633 cell_date = week_monday + timedelta(days=day_offset)634 if cell_date > today:635 grid[day_offset][week_idx] = -1636 else:637 grid[day_offset][week_idx] = date_counts.get(cell_date.isoformat(), 0)638639 return grid, month_labels, start_monday640641642def _intensity_char(count: int, chars: tuple[str, ...]) -> str:643 """Map commit count to a display character with optional color."""644 if count < 0:645 return " "646 idx = min(count, HEATMAP_INTENSITY_LEVELS - 1)647 char = chars[idx]648 if not _SUPPORTS_COLOR or idx == 0:649 return char650 color_fns = (_dim, _yellow, _green)651 return color_fns[idx - 1](char)652653654def _render_heatmap(655 grid: list[list[int]],656 month_labels: list[str],657) -> list[str]:658 """Render the heatmap grid to a list of output lines."""659 chars = HEATMAP_CHARS_COLOR if _SUPPORTS_COLOR else HEATMAP_CHARS_PLAIN660 lines: list[str] = []661662 month_row = " " * HEATMAP_LABEL_WIDTH663 for week_idx in range(len(grid[0])):664 label = month_labels[week_idx]665 if label:666 month_row += label[:3].ljust(2)667 else:668 month_row += " "669 lines.append(month_row.rstrip())670671 for day_idx in range(WEEK_DAYS):672 label = HEATMAP_DAY_LABELS[day_idx].ljust(HEATMAP_LABEL_WIDTH)673 row = label674 for week_idx in range(len(grid[0])):675 row += _intensity_char(grid[day_idx][week_idx], chars) + " "676 lines.append(row.rstrip())677678 return lines679680681def _render_heatmap_legend() -> str:682 """Render the intensity legend line."""683 chars = HEATMAP_CHARS_COLOR if _SUPPORTS_COLOR else HEATMAP_CHARS_PLAIN684 if _SUPPORTS_COLOR:685 parts = [chars[0], _dim(chars[1]), _yellow(chars[2]), _green(chars[3])]686 else:687 parts = list(chars)688 return f" Less {' '.join(parts)} More"689690691# ---------------------------------------------------------------------------692# Display formatting693# ---------------------------------------------------------------------------694695696def _display_commit_compact(commit: LifeCommit) -> None:697 display_date = _format_display_date(commit.commit_date).ljust(8)698 _out(f" {_dim(display_date)} {commit.format_type_scope()}: {commit.message}")699700701def _display_commit_full(commit: LifeCommit) -> None:702 _out(f" {_yellow('commit')} {_yellow(commit.short_id())}")703 _out(" Author: You")704 _out(f" Date: {_format_long_date(commit.commit_date)}")705 _out()706 _out(f"{COMMIT_INDENT}{commit.format_type_scope()}: {commit.message}")707 if commit.details:708 _out()709 _out(f"{COMMIT_INDENT}{commit.details}")710 if commit.tags:711 _out(f"{COMMIT_INDENT}Tags: {', '.join(commit.tags)}")712 _out(f"{COMMIT_INDENT}Mood: {commit.format_mood()} Energy: {commit.energy_score}")713 _out()714 _out(f" {_dim('---')}")715 _out()716717718def _display_commit_recorded(commit: LifeCommit, streak: int) -> None:719 _out()720 _out(f" {_green('✔')} Commit recorded")721 _out()722 _out(f" {_yellow('commit')} {_yellow(commit.short_id())}")723 _out(" Author: You")724 _out(f" Date: {_format_long_date(commit.commit_date)}")725 _out()726 _out(f"{COMMIT_INDENT}{commit.format_type_scope()}: {commit.message}")727 _out()728729 if streak >= STREAK_MIN_DISPLAY:730 _out(f" \U0001f525 {streak} day commit streak")731 _out()732733 if random.random() < REFLECTION_CHANCE: # noqa: S311734 _out(f" {_dim(random.choice(REFLECTION_LINES))}") # noqa: S311735 _out()736737738# ---------------------------------------------------------------------------739# Input helpers740# ---------------------------------------------------------------------------741742743def _prompt_input(prompt_text: str, default: str = "") -> str:744 """Prompt for user input with optional default value."""745 try:746 suffix = f" [{default}]" if default else ""747 response = input(f"{prompt_text}{suffix} ")748 except (EOFError, KeyboardInterrupt):749 _out()750 sys.exit(0)751 stripped = response.strip()752 if not stripped and default:753 return default754 return stripped755756757def _prompt_int(prompt_text: str, default: int, min_val: int, max_val: int) -> int:758 """Prompt for an integer within bounds."""759 raw = _prompt_input(f" {prompt_text} ({min_val}\u2013{max_val})?", str(default))760 try:761 value = int(raw)762 except ValueError:763 return default764 return max(min_val, min(max_val, value))765766767# ---------------------------------------------------------------------------768# Commit creation769# ---------------------------------------------------------------------------770771772def _create_commit(773 message: str,774 *,775 commit_type: str | None = None,776 scope: str = "life",777 details: str = "",778 tags: tuple[str, ...] = (),779 mood: int = MOOD_DEFAULT,780 energy: int = ENERGY_DEFAULT,781) -> LifeCommit:782 """Build a new LifeCommit with generated ID and timestamps."""783 resolved_type = commit_type if commit_type else _detect_commit_type(message)784 return LifeCommit(785 commit_id=str(uuid.uuid4()),786 timestamp=_now_utc_iso(),787 commit_date=_today(),788 commit_type=resolved_type,789 scope=scope,790 message=message,791 details=details,792 tags=tags,793 mood_score=max(MOOD_MIN, min(MOOD_MAX, mood)),794 energy_score=max(ENERGY_MIN, min(ENERGY_MAX, energy)),795 )796797798# ---------------------------------------------------------------------------799# Command handlers800# ---------------------------------------------------------------------------801802803def _cmd_log(full_mode: bool) -> None:804 store = _load_store(STORAGE_FILE)805 if not store.commits:806 _out("\n No commits yet. Start with: commit-your-day\n")807 return808809 commits = list(reversed(store.commits))810811 if full_mode:812 _out()813 _out(f" {_bold('Life Commits')}")814 _out()815 for commit in commits:816 _display_commit_full(commit)817 else:818 display_count = min(LOG_DEFAULT_COUNT, len(commits))819 _out()820 _out(f" {_bold('Recent life commits:')}")821 _out()822 for commit in commits[:display_count]:823 _display_commit_compact(commit)824 remaining = len(commits) - display_count825 if remaining > 0:826 _out()827 _out(f" {_dim(f'... and {remaining} more (use --log --full)')}")828 _out()829830831def _cmd_random() -> None:832 store = _load_store(STORAGE_FILE)833 if not store.commits:834 _out("\n No commits yet. Start with: commit-your-day\n")835 return836837 commit = random.choice(store.commits) # noqa: S311838 time_ago = _relative_time(commit.commit_date)839840 _out()841 _out(f" {_dim(f'From {time_ago}:')}")842 _out()843 _out(f" {commit.format_type_scope()}: {commit.message}")844 if commit.tags:845 _out(f" Tags: {', '.join(commit.tags)}")846 _out(f" Mood: {commit.format_mood()}")847 _out()848849850def _cmd_summary(period: str) -> None:851 store = _load_store(STORAGE_FILE)852 if period == "week":853 cutoff = (date.today() - timedelta(days=WEEK_DAYS)).isoformat()854 filtered = [c for c in store.commits if c.commit_date >= cutoff]855 _display_summary(filtered, "weekly")856857858def _cmd_stats() -> None:859 """Display comprehensive stats with activity heatmap."""860 store = _load_store(STORAGE_FILE)861 commits = store.commits862863 if not commits:864 _out("\n No commits yet. Start with: commit-your-day\n")865 return866867 total = len(commits)868 streak = _compute_streak(commits)869 mood_avg = sum(c.mood_score for c in commits) / total870 energy_avg = sum(c.energy_score for c in commits) / total871872 _out()873 _out(f" {_bold('Life Stats')}")874 _out()875 _out(f" Total commits: {total}")876 _out(f" Current streak: {streak} day{'s' if streak != 1 else ''}")877 _out(f" Mood average: {mood_avg:+.1f}")878 _out(f" Energy average: {energy_avg:.1f}")879 _out()880881 tag_counts = _compute_tag_counts(commits)882 if tag_counts:883 _out(f" {_bold('Top Tags')}")884 _out()885 for tag, count in tag_counts.most_common(TOP_THEMES_COUNT):886 _out(f" {tag:<20s} {count}")887 _out()888889 type_dist = _compute_type_distribution(commits)890 if type_dist:891 _out(f" {_bold('Commit Types')}")892 _out()893 for ctype, count in type_dist:894 _out(f" {ctype:<12s} {count}")895 _out()896897 this_week, last_week = _compute_weekly_trend(commits)898 _out(f" {_bold('Weekly Trend')}")899 _out()900 _out(f" {'':14s} {'This week':>10s} {'Last week':>10s}")901 _out(f" {'Commits':<14s} {this_week['count']:>10} {last_week['count']:>10}")902 _out(903 f" {'Mood avg':<14s} {this_week['mood_avg']:>+10.1f}"904 f" {last_week['mood_avg']:>+10.1f}"905 )906 _out(907 f" {'Energy avg':<14s} {this_week['energy_avg']:>10.1f}"908 f" {last_week['energy_avg']:>10.1f}"909 )910 _out()911912 correlations = _compute_tag_mood_correlation(commits)913 if correlations:914 _out(f" {_bold('Tag-Mood Correlation')}")915 _out()916 for tag, avg_mood, count in correlations:917 _out(f" {tag:<20s} mood {avg_mood:+.1f} ({count} commits)")918 _out()919920 _out(f" {_bold('Activity')}")921 _out()922 grid, month_labels, _start = _build_heatmap_grid(commits)923 heatmap_lines = _render_heatmap(grid, month_labels)924 for line in heatmap_lines:925 _out(f" {line}")926 _out()927 _out(_render_heatmap_legend())928 _out()929930931def _cmd_quick_commit(args: argparse.Namespace) -> None:932 message_raw: str = args.message or ""933 if not message_raw.strip():934 _err(" Error: commit message cannot be empty.")935 sys.exit(1)936937 commit_type: str | None = args.commit_type938 scope: str = args.scope or "life"939 details: str = args.details or ""940941 tags_raw: str | None = args.tags942 tags: tuple[str, ...] = ()943 if tags_raw:944 tags = tuple(t.strip() for t in tags_raw.split(",") if t.strip())945946 mood_val: int | None = args.mood947 mood: int = mood_val if mood_val is not None else MOOD_DEFAULT948949 energy_val: int | None = args.energy950 energy: int = energy_val if energy_val is not None else ENERGY_DEFAULT951952 if mood < MOOD_MIN or mood > MOOD_MAX:953 msg = f"Mood must be between {MOOD_MIN} and {MOOD_MAX}"954 _err(f" Error: {msg}")955 sys.exit(1)956957 if energy < ENERGY_MIN or energy > ENERGY_MAX:958 msg = f"Energy must be between {ENERGY_MIN} and {ENERGY_MAX}"959 _err(f" Error: {msg}")960 sys.exit(1)961962 commit = _create_commit(963 message_raw.strip(),964 commit_type=commit_type,965 scope=scope,966 details=details,967 tags=tags,968 mood=mood,969 energy=energy,970 )971972 store = _load_store(STORAGE_FILE)973 store.commits.append(commit)974 _save_store(STORAGE_FILE, store)975976 streak = _compute_streak(store.commits)977 _display_commit_recorded(commit, streak)978979980def _cmd_interactive() -> None:981 _out()982 raw_message = _prompt_input(" What changed today?\n >")983984 if not raw_message:985 _out("\n Nothing to commit.\n")986 return987988 detected_type = _detect_commit_type(raw_message)989 suggested = f"{detected_type}(life): {raw_message}"990991 _out()992 _out(" Suggested commit:")993 _out()994 _out(f" {_bold(suggested)}")995 _out()996997 accept = _prompt_input(" Accept? (Y/n/edit)", "Y")998999 if accept.lower() == "n":1000 _out("\n Commit discarded.\n")1001 return10021003 if accept.lower() == "edit":1004 raw_message = _prompt_input(" Enter commit message:\n >")1005 if not raw_message:1006 _out("\n Nothing to commit.\n")1007 return1008 detected_type = _detect_commit_type(raw_message)10091010 tags_raw = _prompt_input(" Add tags? (comma separated or skip)")1011 tags: tuple[str, ...] = ()1012 if tags_raw and tags_raw.lower() != "skip":1013 tags = tuple(t.strip() for t in tags_raw.split(",") if t.strip())10141015 energy = _prompt_int("Energy today", ENERGY_DEFAULT, ENERGY_MIN, ENERGY_MAX)1016 mood = _prompt_int("Mood today", MOOD_DEFAULT, MOOD_MIN, MOOD_MAX)10171018 commit = _create_commit(1019 raw_message,1020 commit_type=detected_type,1021 tags=tags,1022 mood=mood,1023 energy=energy,1024 )10251026 store = _load_store(STORAGE_FILE)1027 store.commits.append(commit)1028 _save_store(STORAGE_FILE, store)10291030 streak = _compute_streak(store.commits)1031 _display_commit_recorded(commit, streak)103210331034# ---------------------------------------------------------------------------1035# CLI argument parsing1036# ---------------------------------------------------------------------------103710381039def _build_parser() -> argparse.ArgumentParser:1040 """Construct the argument parser."""1041 parser = argparse.ArgumentParser(1042 prog="commit-your-day",1043 description="Git-style journaling CLI for structured life commits.",1044 )1045 parser.add_argument("-m", "--message", help="quick commit message")1046 parser.add_argument(1047 "--type",1048 dest="commit_type",1049 choices=VALID_COMMIT_TYPES,1050 help="commit type override",1051 )1052 parser.add_argument("--scope", default="life", help="commit scope (default: life)")1053 parser.add_argument("--tags", help="comma-separated tags")1054 parser.add_argument(1055 "--mood",1056 type=int,1057 help=f"mood score ({MOOD_MIN} to {MOOD_MAX})",1058 )1059 parser.add_argument(1060 "--energy",1061 type=int,1062 help=f"energy score ({ENERGY_MIN} to {ENERGY_MAX})",1063 )1064 parser.add_argument("--details", help="extended details for the commit")1065 parser.add_argument("--log", action="store_true", help="view commit history")1066 parser.add_argument(1067 "--full",1068 action="store_true",1069 help="show full commit details (with --log)",1070 )1071 parser.add_argument(1072 "--random",1073 action="store_true",1074 dest="random_commit",1075 help="surface a random past commit",1076 )1077 parser.add_argument(1078 "--summary",1079 choices=["week"],1080 metavar="PERIOD",1081 help="generate summary (week)",1082 )1083 parser.add_argument(1084 "--stats",1085 action="store_true",1086 help="show comprehensive stats with activity heatmap",1087 )1088 return parser108910901091# ---------------------------------------------------------------------------1092# Entry point1093# ---------------------------------------------------------------------------109410951096def main() -> None:1097 """CLI entry point."""1098 logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")10991100 parser = _build_parser()1101 args = parser.parse_args()1102 _ensure_storage_dir()11031104 if args.log:1105 _cmd_log(full_mode=bool(args.full))1106 elif args.random_commit:1107 _cmd_random()1108 elif args.summary:1109 _cmd_summary(period=str(args.summary))1110 elif args.stats:1111 _cmd_stats()1112 elif args.message:1113 _cmd_quick_commit(args)1114 else:1115 _cmd_interactive()111611171118if __name__ == "__main__":1119 main()1120