dayplan.py
python · 430 lines
1#!/usr/bin/env python32"""Tiny Day Planner: a calm CLI micro-planner for daily task prioritization.34Transforms a list of tasks into a clear, human-readable daily execution plan.5Outputs the plan to stdout and writes a JSON session file for later reference.6"""78from __future__ import annotations910import argparse11import json12import random13import sys14from collections.abc import Sequence15from dataclasses import dataclass16from datetime import datetime, timezone17from typing import NoReturn1819# ---------------------------------------------------------------------------20# Constants21# ---------------------------------------------------------------------------2223SESSION_FILENAME: str = "dayplan_session.json"24MAX_TASKS: int = 5025MAX_CORE: int = 52627_ENERGY_CHOICES: tuple[str, ...] = ("low", "medium", "high")28_FOCUS_CHOICES: tuple[str, ...] = ("work", "personal", "health", "study", "mixed")29_STYLE_CHOICES: tuple[str, ...] = ("soft", "neutral", "direct")3031_ENERGY_CORE_MAP: dict[str, int] = {32 "low": 1,33 "medium": 2,34 "high": 3,35}3637_CLOSING_LINES: dict[str, tuple[str, ...]] = {38 "soft": (39 "One thing at a time.",40 "That's enough.",41 "You know what matters today.",42 "Begin whenever you're ready.",43 "Small steps count.",44 ),45 "neutral": (46 "Proceed sequentially.",47 "Plan outlined above.",48 "Reference this list as needed.",49 ),50 "direct": (51 "Start now.",52 "Begin with the first task.",53 "Proceed.",54 ),55}5657_CORE_LABELS: dict[str, tuple[str, str, str]] = {58 "soft": ("Start with:", "Then:", "If energy allows:"),59 "neutral": ("First:", "Next:", "Additionally:"),60 "direct": ("First:", "Next:", "Then:"),61}6263_OPTIONAL_LABELS: dict[str, str] = {64 "soft": "If there's space later:",65 "neutral": "Optional:",66 "direct": "Remaining:",67}6869_HEADER_NAMED: dict[str, str] = {70 "soft": "{name}'s gentle plan for today:",71 "neutral": "{name}'s plan for today:",72 "direct": "{name}'s plan for today:",73}7475_HEADER_UNNAMED: dict[str, str] = {76 "soft": "Today's gentle plan:",77 "neutral": "Today's plan:",78 "direct": "Today's plan:",79}808182# ---------------------------------------------------------------------------83# Data structures84# ---------------------------------------------------------------------------858687@dataclass(frozen=True, slots=True)88class PlanConfig:89 """Validated, immutable configuration derived from CLI arguments."""9091 tasks: tuple[str, ...]92 name: str | None = None93 energy: str = "medium"94 focus: str | None = None95 max_core: int | None = None96 style: str = "soft"979899@dataclass(frozen=True, slots=True)100class Plan:101 """A fully resolved execution plan ready for formatting and export."""102103 core_tasks: tuple[str, ...]104 optional_tasks: tuple[str, ...]105 header: str106 focus_line: str | None107 closing_line: str108 config: PlanConfig109110111# ---------------------------------------------------------------------------112# Argument parsing113# ---------------------------------------------------------------------------114115116class _DayPlanParser(argparse.ArgumentParser):117 """ArgumentParser that exits with code 1 on all argument errors."""118119 def error(self, message: str) -> NoReturn:120 """Print error to stderr and exit with code 1."""121 print(f"Error: {message}", file=sys.stderr) # noqa: T201122 sys.exit(1)123124125def _build_parser() -> _DayPlanParser:126 """Construct the CLI argument parser with all supported flags."""127 parser = _DayPlanParser(128 description=(129 "Tiny Day Planner \u2014 transform tasks into a calm,"130 " human-readable daily plan."131 ),132 )133 parser.add_argument(134 "--tasks",135 type=str,136 default=None,137 help=(138 'Comma-separated list of tasks (e.g. "email boss, grocery run, workout")'139 ),140 )141 parser.add_argument(142 "--name",143 type=str,144 default=None,145 help="Personalize the plan header with your name",146 )147 parser.add_argument(148 "--energy",149 type=str,150 choices=_ENERGY_CHOICES,151 default="medium",152 help="Energy level controlling emphasized task count (default: medium)",153 )154 parser.add_argument(155 "--focus",156 type=str,157 choices=_FOCUS_CHOICES,158 default=None,159 help="Theme for framing the plan tone",160 )161 parser.add_argument(162 "--max-core",163 type=int,164 default=None,165 help="Override automatic core task count (1\u20135)",166 )167 parser.add_argument(168 "--style",169 type=str,170 choices=_STYLE_CHOICES,171 default="soft",172 help="Output tone: soft, neutral, or direct (default: soft)",173 )174 return parser175176177# ---------------------------------------------------------------------------178# Parsing and validation179# ---------------------------------------------------------------------------180181182def _parse_task_string(raw: str) -> tuple[str, ...]:183 """Parse a comma-separated task string into a normalized tuple.184185 Trims whitespace from each token, discards empty tokens, and preserves186 both duplicates and the original ordering provided by the user.187 """188 tasks: list[str] = []189 for token in raw.split(","):190 cleaned = token.strip()191 if cleaned:192 tasks.append(cleaned)193 return tuple(tasks)194195196def _validate_and_build_config(args: argparse.Namespace) -> PlanConfig:197 """Validate parsed CLI arguments and construct a PlanConfig.198199 Enforces all spec-mandated constraints. Prints error messages to stderr200 and exits with code 1 on any validation failure.201 """202 if args.tasks is None:203 print("Error: No tasks provided.", file=sys.stderr) # noqa: T201204 print('Use --tasks "task1, task2"', file=sys.stderr) # noqa: T201205 sys.exit(1)206207 tasks = _parse_task_string(args.tasks)208209 if not tasks:210 print("Error: No tasks provided.", file=sys.stderr) # noqa: T201211 print('Use --tasks "task1, task2"', file=sys.stderr) # noqa: T201212 sys.exit(1)213214 if len(tasks) > MAX_TASKS:215 print( # noqa: T201216 f"Error: Maximum supported tasks is {MAX_TASKS}.",217 file=sys.stderr,218 )219 sys.exit(1)220221 max_core: int | None = args.max_core222 if max_core is not None and not 1 <= max_core <= MAX_CORE:223 print( # noqa: T201224 f"Error: --max-core must be between 1 and {MAX_CORE}.",225 file=sys.stderr,226 )227 sys.exit(1)228229 return PlanConfig(230 tasks=tasks,231 name=args.name,232 energy=args.energy,233 focus=args.focus,234 max_core=max_core,235 style=args.style,236 )237238239# ---------------------------------------------------------------------------240# Planning logic241# ---------------------------------------------------------------------------242243244def _resolve_core_count(config: PlanConfig) -> int:245 """Determine the number of core tasks.246247 Uses --max-core if provided; otherwise derives from energy level.248 The result is clamped to the actual number of available tasks.249 """250 if config.max_core is not None:251 limit = config.max_core252 else:253 limit = _ENERGY_CORE_MAP[config.energy]254 return min(limit, len(config.tasks))255256257def _classify_tasks(258 config: PlanConfig,259) -> tuple[tuple[str, ...], tuple[str, ...]]:260 """Split tasks into core and optional groups.261262 The first N tasks become core, preserving user-provided ordering.263 All remaining tasks become optional.264 """265 n = _resolve_core_count(config)266 return config.tasks[:n], config.tasks[n:]267268269def _build_header(config: PlanConfig) -> str:270 """Generate the plan header line based on name and style."""271 if config.name is not None:272 return _HEADER_NAMED[config.style].format(name=config.name)273 return _HEADER_UNNAMED[config.style]274275276def _build_focus_line(config: PlanConfig) -> str | None:277 """Generate the focus annotation line, or None if unspecified."""278 if config.focus is None:279 return None280 return f"Focus: {config.focus.capitalize()}"281282283def _select_closing_line(style: str) -> str:284 """Select a random closing line appropriate to the given style."""285 return random.choice(_CLOSING_LINES[style]) # noqa: S311286287288def _build_plan(config: PlanConfig) -> Plan:289 """Construct a fully resolved Plan from validated configuration."""290 core, optional = _classify_tasks(config)291 return Plan(292 core_tasks=core,293 optional_tasks=optional,294 header=_build_header(config),295 focus_line=_build_focus_line(config),296 closing_line=_select_closing_line(config.style),297 config=config,298 )299300301# ---------------------------------------------------------------------------302# Formatting303# ---------------------------------------------------------------------------304305306def _format_core_section(tasks: tuple[str, ...], style: str) -> str:307 """Format the core task section with style-appropriate labels.308309 Returns an empty string when no core tasks exist.310 """311 if not tasks:312 return ""313314 first_label, second_label, rest_label = _CORE_LABELS[style]315 lines: list[str] = [first_label, f"\u2022 {tasks[0]}"]316317 if len(tasks) >= 2:318 lines.append("")319 lines.append(second_label)320 lines.append(f"\u2022 {tasks[1]}")321322 if len(tasks) >= 3:323 lines.append("")324 lines.append(rest_label)325 for task in tasks[2:]:326 lines.append(f"\u2022 {task}")327328 return "\n".join(lines)329330331def _format_optional_section(tasks: tuple[str, ...], style: str) -> str:332 """Format the optional task section.333334 Returns an empty string when no optional tasks exist, causing the335 section to be omitted from the final output.336 """337 if not tasks:338 return ""339340 lines: list[str] = [_OPTIONAL_LABELS[style]]341 for task in tasks:342 lines.append(f"\u2022 {task}")343 return "\n".join(lines)344345346def _format_plan(plan: Plan) -> str:347 """Render a Plan into the complete human-readable output string."""348 sections: list[str] = []349350 header_block = plan.header351 if plan.focus_line is not None:352 header_block = f"{header_block}\n{plan.focus_line}"353 sections.append(header_block)354355 core = _format_core_section(plan.core_tasks, plan.config.style)356 if core:357 sections.append(core)358359 optional = _format_optional_section(360 plan.optional_tasks,361 plan.config.style,362 )363 if optional:364 sections.append(optional)365366 sections.append(plan.closing_line)367368 return "\n\n".join(sections)369370371# ---------------------------------------------------------------------------372# JSON session export373# ---------------------------------------------------------------------------374375376def _export_session(plan: Plan) -> None:377 """Write the current plan to a JSON session file.378379 The file is placed in the current working directory, overwriting any380 previous session. On failure, a warning is printed to stderr but381 execution continues and exit code remains 0.382 """383 session: dict[str, str | list[str] | None] = {384 "timestamp": datetime.now(tz=timezone.utc).isoformat(),385 "name": plan.config.name,386 "focus": plan.config.focus,387 "energy": plan.config.energy,388 "core_tasks": list(plan.core_tasks),389 "optional_tasks": list(plan.optional_tasks),390 "style": plan.config.style,391 "closing_line": plan.closing_line,392 }393 try:394 with open(SESSION_FILENAME, "w", encoding="utf-8") as f:395 json.dump(session, f, indent=2, ensure_ascii=False)396 f.write("\n")397 except OSError as exc:398 print( # noqa: T201399 f"Warning: Could not write session file: {exc}",400 file=sys.stderr,401 )402403404# ---------------------------------------------------------------------------405# Entry point406# ---------------------------------------------------------------------------407408409def main(argv: Sequence[str] | None = None) -> int:410 """Run the Tiny Day Planner.411412 Parses CLI arguments, generates a calm daily plan, prints the plan to413 stdout, and writes a JSON session file. Returns 0 on success.414 """415 parser = _build_parser()416 args = parser.parse_args(argv)417 config = _validate_and_build_config(args)418419 plan = _build_plan(config)420 output = _format_plan(plan)421422 print(output) # noqa: T201423 _export_session(plan)424425 return 0426427428if __name__ == "__main__":429 sys.exit(main())430