student-task-manager-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: student-task-manager-cli
3
+ Version: 0.1.0
4
+ Summary: A simple command-line task manager
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+
8
+ ## Install
9
+
10
+ From the project directory, on macOS or Linux:
11
+
12
+ ```bash
13
+ python3 -m venv .venv
14
+ source .venv/bin/activate
15
+ python -m pip install -e .
16
+ ```
17
+
18
+ On Windows PowerShell:
19
+
20
+ ```powershell
21
+ py -m venv .venv
22
+ .venv\Scripts\Activate.ps1
23
+ python -m pip install -e .
24
+ ```
25
+
26
+ After activation, run `task` to open the interactive terminal view.
27
+ Enter commands at the `❯` prompt, for example `add Write report`, `ls`, or `/help`.
28
+ `list` and `ls` are the same command. `list` shows To do tasks, `list --done` shows completed tasks, and `list --all` shows both.
29
+ Titles may contain spaces without quotes. `clear` and `cls` clear the interactive screen;
30
+ `clear-completed` removes completed tasks. Type `task` at the prompt to show the welcome view again.
31
+
32
+ Tab completes commands and the arrow keys recall history on terminals with readline.
33
+ The welcome screen shows your next task and a small activity chart.
34
+
35
+ You can also run single commands directly, such as:
36
+ `task add "Write report"` or `task --help`.
37
+
38
+ To leave the environment, run `deactivate`.
39
+
40
+ ## Commands
41
+
42
+ | Command | Purpose |
43
+ | --- | --- |
44
+ | `task add "Write report"` | Add a task |
45
+ | `task list` or `task ls` | List To do tasks |
46
+ | `task list --done` | List completed tasks |
47
+ | `task list --undone` | List To do tasks |
48
+ | `task list --all` | List every task |
49
+ | `task done 1` | Complete task 1 |
50
+ | `task undone 1` | Make task 1 pending |
51
+ | `task delete 1` | Delete task 1 |
52
+ | `task update 1 "New title"` | Change task 1's title |
53
+ | `task show 1` | Show task 1's details |
54
+ | `task clear` | Delete completed tasks |
55
+ | `task stats` | Show progress and a 7-day completion chart |
56
+ | `task demo` | Try 26 sample tasks in a temporary workspace |
57
+ | `task help` | Show all commands |
58
+
59
+ Also available: `task --help`, `task --version`, and `task <command> --help` (for example, `task add --help`). IDs are positive integers. After deletion or a status change, tasks are renumbered from 1, with To do tasks first. Use `list --all` to see current IDs. Existing task files are normalized when read; new IDs are saved on the next change. Titles cannot be blank.
60
+
61
+ ## Try it with sample data
62
+
63
+ ```bash
64
+ task demo
65
+ ```
66
+
67
+ Or run `python test_task.py` from the project directory. Both open a separate
68
+ session with 8 pending and 18 completed tasks spread across the last week.
69
+ Try `ls`, `ls --done`, `done 1`, then `stats`. Type `/exit` to leave.
70
+ Demo changes are discarded; your own tasks stay untouched.
71
+ `task demo --preview` prints the sample list and chart without opening a prompt.
72
+
73
+ ## Progress
74
+
75
+ `stats` shows task counts, completion percentage, and a daily chart:
76
+
77
+ ```text
78
+ Progress ━━━━━━━━━━━━━━────── 69%
79
+
80
+ This week · completed tasks
81
+
82
+ Tue 06 ━━━━ 1
83
+ Wed 07 ━━━━━━━━━━━━ 3
84
+ Thu 08 ━━━━━━━━ 2
85
+ Fri 09 ━━━━━━━━━━━━━━━━ 4
86
+ Sat 10 ━━━━ 1
87
+ Sun 11 ━━━━━━━━━━━━ 3
88
+ Mon 12 ━━━━━━━━━━━━━━━━ 4
89
+ ```
90
+
91
+ Dates follow your local timezone. `done` records the completion time; `undone`
92
+ removes it. The chart counts tasks still in the file, so deleting completed tasks
93
+ also removes them from the chart. Older tasks without a completion date still
94
+ count toward the total, but not the daily chart.
95
+
96
+ ## Project structure
97
+
98
+ ```text
99
+ src/task_manager/
100
+ cli.py Entry point: one command or interactive mode
101
+ arguments.py Command names, flags, and ls alias
102
+ commands.py Add, edit, complete, delete, list
103
+ storage.py JSON file, validation, task IDs
104
+ productivity.py Weekly counts and progress
105
+ input.py Parse commands typed at the prompt
106
+ interactive.py Input loop and command history
107
+ demo.py Sample tasks and temporary workspace
108
+ ui/
109
+ theme.py Colors and terminal sizing
110
+ animation.py Startup and result transitions
111
+ views.py Welcome screen, help, and task list
112
+ tests/ CLI, interactive, demo, and productivity tests
113
+ test_task.py Start the demo from the project directory
114
+ ```
115
+
116
+ A command goes through `cli → arguments → commands → storage`.
117
+ Interactive mode adds `input` and `ui`; both modes use the same commands.
118
+ No runtime dependencies. Set `TASK_MANAGER_NO_ANIMATION=1` to disable motion,
119
+ or `NO_COLOR=1` for plain output. Redirected output has no colors or animation.
120
+
121
+ ## Where tasks are stored
122
+
123
+ The file is `tasks.json` in `~/Library/Application Support/task-manager/` on macOS, `%APPDATA%\task-manager\` on Windows, or `${XDG_DATA_HOME:-~/.local/share}/task-manager/` on Linux. It is created when a command saves tasks. A missing or empty file means no tasks. If JSON is corrupted, the command reports an error and leaves the file intact.
124
+
125
+ ## Run tests
126
+
127
+ With the virtual environment active, install pytest separately for development:
128
+
129
+ ```bash
130
+ python -m pip install pytest
131
+ python -m pytest
132
+ ```
133
+
134
+ Tests set `TASK_MANAGER_DATA_DIR` to a temporary directory. Your real task data is never cleared by the test suite.
@@ -0,0 +1,19 @@
1
+ task_manager/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ task_manager/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
3
+ task_manager/arguments.py,sha256=9Hq4VHDBfUfRyueN3VmFLd9Qr_wJSw004lA2OujhSpw,2157
4
+ task_manager/cli.py,sha256=RblrY0GU5C9cDDWF3-1K59QPXVfLdNeG7EMbmE1xC9c,563
5
+ task_manager/commands.py,sha256=5nXQhrglLo-XE6NiQc8WgjWKm972jqTjgoZ-xUCzOdE,3213
6
+ task_manager/demo.py,sha256=-qfyP5anpaHAXmBsJmfKmQKaVZmJFBdl2R1jbJXr1m8,2950
7
+ task_manager/input.py,sha256=cmV2_xQZNeYChEMb_5b2ZYSZ3ljlhrQUsTsXD9IUcfk,1210
8
+ task_manager/interactive.py,sha256=YZsFwuh9CNTQVsWvu8i1XLiZJnK7b4YqNv-eoQr9L7M,3226
9
+ task_manager/productivity.py,sha256=LRwnDIfG6wC7wYudVoeU_X1Qb_GMCkZQiNX7M7ewriU,1795
10
+ task_manager/storage.py,sha256=Cbrd8AH_gETRhalO4nwF6med-8HAssBoUp0WtHHYcwM,2911
11
+ task_manager/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ task_manager/ui/animation.py,sha256=yxexybBlelYpRe6Cdm-9Vb-2ajFNkuJ3MORlUth3f7I,681
13
+ task_manager/ui/theme.py,sha256=7eqxQsPuKgC4DryiYcxtAItXYtYtJLfdG9cjSRaljbI,1360
14
+ task_manager/ui/views.py,sha256=5tYxwg2i7P08pRQvzQ5W_pDVR1J4t-vYHi0fJ4iy_qs,5489
15
+ student_task_manager_cli-0.1.0.dist-info/METADATA,sha256=MwVTU_xu5s80AUrlp7sTt4sknBBLSQP0E69NXmx1Axk,5240
16
+ student_task_manager_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
17
+ student_task_manager_cli-0.1.0.dist-info/entry_points.txt,sha256=r3oqZvrjOl-rGX2jprgCiv-ejzWlbQmZv-le8hB6KyA,47
18
+ student_task_manager_cli-0.1.0.dist-info/top_level.txt,sha256=txVWUWwaSeBBGIFJea7iM-OGDJJYKyVuQPPXqt-CXyw,13
19
+ student_task_manager_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ task = task_manager.cli:main
@@ -0,0 +1 @@
1
+ task_manager
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,52 @@
1
+ import argparse
2
+
3
+ from . import __version__
4
+
5
+
6
+ def positive_id(value: str) -> int:
7
+ try:
8
+ task_id = int(value)
9
+ except ValueError as error:
10
+ raise argparse.ArgumentTypeError("task ID must be a positive number") from error
11
+ if task_id < 1:
12
+ raise argparse.ArgumentTypeError("task ID must be a positive number")
13
+ return task_id
14
+
15
+
16
+ def parser_for_cli() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(prog="task", description="Manage your tasks.")
18
+ parser.add_argument("--version", action="version", version=f"task {__version__}")
19
+ commands = parser.add_subparsers(dest="command", required=True)
20
+ descriptions = {
21
+ "add": "Add a new task",
22
+ "list": "List pending tasks (or filter by status)",
23
+ "done": "Mark a task as completed",
24
+ "undone": "Mark a task as pending",
25
+ "delete": "Delete a task",
26
+ "update": "Change a task title",
27
+ "show": "Show task details",
28
+ "clear": "Delete completed tasks",
29
+ "stats": "Show weekly productivity",
30
+ "demo": "Open a session with sample tasks",
31
+ "help": "Show all commands",
32
+ }
33
+ for name, description in descriptions.items():
34
+ command = commands.add_parser(
35
+ name, aliases=["ls"] if name == "list" else [],
36
+ help=description, description=description,
37
+ )
38
+ command.set_defaults(command=name)
39
+ if name in ("done", "undone", "delete", "update", "show"):
40
+ command.add_argument("id", type=positive_id, help="positive task ID")
41
+ if name in ("add", "update"):
42
+ command.add_argument("title", help="task title")
43
+ if name == "demo":
44
+ command.add_argument("--preview", action="store_true", help="print the demo and exit")
45
+ if name == "list":
46
+ filters = command.add_mutually_exclusive_group()
47
+ filters.add_argument("--done", action="store_true", help="show completed tasks")
48
+ filters.add_argument("--undone", action="store_true", help="show pending tasks")
49
+ filters.add_argument("--all", action="store_true", help="show every task")
50
+ return parser
51
+
52
+
task_manager/cli.py ADDED
@@ -0,0 +1,22 @@
1
+ import sys
2
+
3
+ from .arguments import parser_for_cli
4
+ from .commands import execute
5
+
6
+
7
+ def main(argv: list[str] | None = None) -> int:
8
+ argv = sys.argv[1:] if argv is None else argv
9
+ if not argv:
10
+ from .interactive import run_interactive
11
+
12
+ return run_interactive(main)
13
+ parser = parser_for_cli()
14
+ args = parser.parse_args(argv)
15
+ if args.command == "help":
16
+ parser.print_help()
17
+ return 0
18
+ if args.command == "demo":
19
+ from .demo import run_demo
20
+
21
+ return run_demo(preview=args.preview)
22
+ return execute(args)
@@ -0,0 +1,75 @@
1
+ import sys
2
+ from datetime import datetime, timezone
3
+
4
+ from .productivity import format_stats
5
+ from .storage import StorageError, load_data, save_data
6
+
7
+
8
+ def execute(args) -> int:
9
+ if args.command in ("add", "update"):
10
+ title = args.title.strip()
11
+ if not title:
12
+ print("Error: task title cannot be empty.", file=sys.stderr)
13
+ return 1
14
+
15
+ try:
16
+ data = load_data()
17
+ tasks = data["tasks"]
18
+
19
+ if args.command == "add":
20
+ task = {
21
+ "id": data["next_id"],
22
+ "title": title,
23
+ "status": "pending",
24
+ "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
25
+ }
26
+ tasks.append(task)
27
+ save_data(data)
28
+ print(f"Added task {task['id']}: {title}")
29
+ elif args.command == "list":
30
+ visible = [task for task in tasks if args.all or task["status"] == ("completed" if args.done else "pending")]
31
+ if not visible:
32
+ print("No tasks yet." if not tasks else "No completed tasks." if args.done else "No pending tasks.")
33
+ for task in visible:
34
+ print(f"{task['id']}. [{task['status']}] {task['title']}")
35
+ elif args.command == "stats":
36
+ print(format_stats(tasks))
37
+ elif args.command == "clear":
38
+ remaining = [task for task in tasks if task["status"] != "completed"]
39
+ removed = len(tasks) - len(remaining)
40
+ data["tasks"] = remaining
41
+ save_data(data)
42
+ print(f"Removed {removed} completed task(s).")
43
+ else:
44
+ task = next((task for task in tasks if task["id"] == args.id), None)
45
+ if task is None:
46
+ print(f"Error: task {args.id} not found.", file=sys.stderr)
47
+ return 1
48
+ if args.command == "show":
49
+ print(f"ID: {task['id']}\nTitle: {task['title']}\nStatus: {task['status']}\nCreated: {task['created_at']}")
50
+ if task.get("completed_at"):
51
+ print(f"Completed: {task['completed_at']}")
52
+ elif args.command == "delete":
53
+ tasks.remove(task)
54
+ save_data(data)
55
+ print(f"Deleted task {args.id}.")
56
+ else:
57
+ if args.command == "update":
58
+ task["title"] = title
59
+ else:
60
+ if args.command == "done" and task["status"] != "completed":
61
+ task["completed_at"] = datetime.now(timezone.utc).isoformat(timespec="seconds")
62
+ elif args.command == "undone":
63
+ task.pop("completed_at", None)
64
+ task["status"] = "completed" if args.command == "done" else "pending"
65
+ save_data(data)
66
+ if args.command == "done":
67
+ print(f"Completed: {task['title']}")
68
+ elif args.command == "undone":
69
+ print(f"Back to To do: {task['title']}")
70
+ else:
71
+ print(f"Renamed task {task['id']}: {task['title']}")
72
+ except StorageError as error:
73
+ print(f"Error: {error}", file=sys.stderr)
74
+ return 1
75
+ return 0
task_manager/demo.py ADDED
@@ -0,0 +1,69 @@
1
+ import os
2
+ from datetime import datetime, timedelta
3
+ from tempfile import TemporaryDirectory
4
+
5
+ from .storage import save_data
6
+
7
+ PENDING = (
8
+ "Prepare the CLI project presentation",
9
+ "Draw the module diagram for the teacher",
10
+ "Practice explaining JSON storage",
11
+ "Review Python decorators",
12
+ "Finish database homework",
13
+ "Read 15 pages of Clean Code",
14
+ "Plan next week's study sessions",
15
+ "Push the final package to GitHub",
16
+ )
17
+ COMPLETED = (
18
+ "Set up the virtual environment", "Create the package structure",
19
+ "Build the add command", "Save tasks as JSON", "Handle missing files",
20
+ "Implement list filters", "Validate task IDs", "Add update and delete",
21
+ "Write storage tests", "Test Unicode titles", "Fix empty task names",
22
+ "Add command history", "Design the welcome screen", "Add the ls alias",
23
+ "Build the weekly chart", "Check the README examples", "Run the test suite",
24
+ "Rehearse the project demo",
25
+ )
26
+
27
+
28
+ def sample_data(now: datetime | None = None) -> dict:
29
+ now = now or datetime.now().astimezone()
30
+ tasks = []
31
+ for title in PENDING:
32
+ tasks.append({"id": len(tasks) + 1, "title": title, "status": "pending",
33
+ "created_at": (now - timedelta(days=2)).isoformat(timespec="seconds")})
34
+ titles = iter(COMPLETED)
35
+ for days_ago, count in zip(range(6, -1, -1), (1, 3, 2, 4, 1, 3, 4)):
36
+ completed = now - timedelta(days=days_ago)
37
+ for _ in range(count):
38
+ tasks.append({"id": len(tasks) + 1, "title": next(titles), "status": "completed",
39
+ "created_at": (completed - timedelta(days=1)).isoformat(timespec="seconds"),
40
+ "completed_at": completed.isoformat(timespec="seconds")})
41
+ return {"next_id": len(tasks) + 1, "tasks": tasks}
42
+
43
+
44
+ def run_demo(preview: bool = False) -> int:
45
+ from .cli import main
46
+ from .productivity import format_stats
47
+ from .ui.views import show_result, welcome
48
+
49
+ previous = os.environ.get("TASK_MANAGER_DATA_DIR")
50
+ with TemporaryDirectory(prefix="task-manager-demo-") as directory:
51
+ os.environ["TASK_MANAGER_DATA_DIR"] = directory
52
+ try:
53
+ data = sample_data()
54
+ save_data(data)
55
+ print("\n DEMO · 26 sample tasks · changes last only for this session")
56
+ if preview:
57
+ welcome()
58
+ message = "\n".join(f"{task['id']}. [{task['status']}] {task['title']}" for task in data["tasks"])
59
+ show_result("list", message, False, ["list", "--all"])
60
+ print()
61
+ show_result("stats", format_stats(data["tasks"]), False)
62
+ return 0
63
+ print(" Try ls, ls --done, done 1, stats. /exit leaves the demo.")
64
+ return main([])
65
+ finally:
66
+ if previous is None:
67
+ os.environ.pop("TASK_MANAGER_DATA_DIR", None)
68
+ else:
69
+ os.environ["TASK_MANAGER_DATA_DIR"] = previous
task_manager/input.py ADDED
@@ -0,0 +1,36 @@
1
+ import shlex
2
+
3
+
4
+ def parse_command(line: str) -> list[str]:
5
+ line = line.strip()
6
+ if line.startswith("/"):
7
+ line = line[1:]
8
+ parts = line.split(maxsplit=1)
9
+ if not parts:
10
+ return ["help"]
11
+ command, rest = parts[0], parts[1] if len(parts) > 1 else ""
12
+ command = command.lower()
13
+ command = "list" if command == "ls" else command
14
+ rest = rest.strip()
15
+ if command == "task":
16
+ return parse_command(rest) if rest else ["home"]
17
+ if rest in {"--help", "-h"}:
18
+ return [command, rest]
19
+ if command == "add":
20
+ if rest.startswith(('"', "'")):
21
+ words = shlex.split(rest)
22
+ if len(words) == 1:
23
+ rest = words[0]
24
+ return [command, rest] if rest else [command]
25
+ if command == "update":
26
+ parts = rest.split(maxsplit=1)
27
+ task_id = parts[0] if parts else ""
28
+ title = parts[1].strip() if len(parts) > 1 else ""
29
+ if title.startswith(('"', "'")):
30
+ words = shlex.split(title)
31
+ if len(words) == 1:
32
+ title = words[0]
33
+ return [command, task_id, title] if title else [command, task_id] if task_id else [command]
34
+ return [command, *shlex.split(rest)]
35
+
36
+
@@ -0,0 +1,97 @@
1
+ import contextlib
2
+ import io
3
+ import sys
4
+ from collections.abc import Callable
5
+
6
+ from .input import parse_command
7
+ from .ui.animation import startup
8
+ from .ui.theme import MUTED, paint, prompt, rule, styled
9
+ from .ui.views import show_help, show_result, welcome
10
+
11
+ COMMANDS = {"add", "list", "ls", "done", "undone", "delete", "update", "show",
12
+ "clear-completed", "stats", "demo"}
13
+
14
+
15
+ @contextlib.contextmanager
16
+ def completion():
17
+ try:
18
+ import readline
19
+ except ImportError:
20
+ yield
21
+ return
22
+ previous = readline.get_completer()
23
+ words = sorted(COMMANDS | {"help", "exit", "clear", "cls", "task", "home"})
24
+
25
+ def complete(text: str, state: int) -> str | None:
26
+ prefix = "/" if text.startswith("/") else ""
27
+ matches = [prefix + word + " " for word in words if word.startswith(text.lstrip("/"))]
28
+ return matches[state] if state < len(matches) else None
29
+
30
+ delimiters = readline.get_completer_delims()
31
+ readline.set_completer_delims(" \t\n")
32
+ readline.set_completer(complete)
33
+ binding = "bind ^I rl_complete" if "libedit" in (readline.__doc__ or "") else "tab: complete"
34
+ readline.parse_and_bind(binding)
35
+ try:
36
+ yield
37
+ finally:
38
+ readline.set_completer(previous)
39
+ readline.set_completer_delims(delimiters)
40
+
41
+
42
+ def run_interactive(execute: Callable[[list[str]], int]) -> int:
43
+ try:
44
+ with completion():
45
+ startup()
46
+ welcome()
47
+ return command_loop(execute)
48
+ except (EOFError, KeyboardInterrupt):
49
+ print("\n Goodbye.")
50
+ return 0
51
+
52
+
53
+ def command_loop(execute: Callable[[list[str]], int]) -> int:
54
+ while True:
55
+ if styled():
56
+ print(paint(" /help for commands · Tab to complete", MUTED))
57
+ rule()
58
+ line = input(prompt())
59
+ if not line.strip():
60
+ continue
61
+ if styled():
62
+ rule()
63
+ try:
64
+ args = parse_command(line)
65
+ except ValueError as error:
66
+ show_result("", str(error), True)
67
+ continue
68
+ command = args[0]
69
+ if command in {"exit", "quit"}:
70
+ print(" Goodbye.")
71
+ return 0
72
+ if command == "home":
73
+ welcome()
74
+ continue
75
+ if command in {"clear", "cls", "clear-screen"}:
76
+ print("\x1b[2J\x1b[H" if styled() else "", end="")
77
+ welcome()
78
+ continue
79
+ if command == "help":
80
+ show_help()
81
+ elif command not in COMMANDS:
82
+ show_result(command, f"Unknown command: {command}. Type /help for commands.", True)
83
+ elif command == "demo":
84
+ try:
85
+ execute(args)
86
+ except SystemExit:
87
+ pass
88
+ welcome()
89
+ else:
90
+ output, errors = io.StringIO(), io.StringIO()
91
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(errors):
92
+ try:
93
+ result = execute(["clear", *args[1:]] if command == "clear-completed" else args)
94
+ except SystemExit as error:
95
+ result = error.code
96
+ show_result(command, (errors if result else output).getvalue().strip(), bool(result), args)
97
+ print()
@@ -0,0 +1,37 @@
1
+ from datetime import date, datetime, timedelta
2
+
3
+
4
+ def weekly_counts(tasks: list[dict], today: date | None = None) -> list[tuple[date, int]]:
5
+ today = today or datetime.now().astimezone().date()
6
+ counts = {today - timedelta(days=offset): 0 for offset in range(6, -1, -1)}
7
+ for task in tasks:
8
+ if task["status"] != "completed" or not task.get("completed_at"):
9
+ continue
10
+ try:
11
+ completed = datetime.fromisoformat(task["completed_at"]).astimezone().date()
12
+ except (ValueError, TypeError):
13
+ continue
14
+ if completed in counts:
15
+ counts[completed] += 1
16
+ return list(counts.items())
17
+
18
+
19
+ def format_stats(tasks: list[dict], today: date | None = None) -> str:
20
+ completed = sum(task["status"] == "completed" for task in tasks)
21
+ total = len(tasks)
22
+ percent = round(100 * completed / total) if total else 0
23
+ filled = round(20 * completed / total) if total else 0
24
+ lines = [f"Total: {total}", f"Completed: {completed}", f"Pending: {total - completed}", "",
25
+ f"Progress {'━' * filled}{'─' * (20 - filled)} {percent}%", "",
26
+ "This week · completed tasks", ""]
27
+ week = weekly_counts(tasks, today)
28
+ peak = max(count for _, count in week) or 1
29
+ for day, count in week:
30
+ bar = '━' * max(1, round(count / peak * 16)) if count else '·'
31
+ lines.append(f"{day:%a %d} {bar:<16} {count}")
32
+ lines += ["", f"{sum(count for _, count in week)} completed in the last 7 days"]
33
+ undated = sum(task["status"] == "completed" and not task.get("completed_at") for task in tasks)
34
+ if undated:
35
+ lines.append(f"{undated} older completed task(s) without a date")
36
+ lines.append("Chart uses completion dates of tasks still in your list.")
37
+ return "\n".join(lines)
@@ -0,0 +1,78 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ class StorageError(Exception):
8
+ pass
9
+
10
+
11
+ def normalize_data(data: dict) -> None:
12
+ """Pending tasks first, IDs from one."""
13
+ tasks = data["tasks"]
14
+ tasks[:] = ([task for task in tasks if task["status"] == "pending"]
15
+ + [task for task in tasks if task["status"] == "completed"])
16
+ for task_id, task in enumerate(tasks, start=1):
17
+ task["id"] = task_id
18
+ data["next_id"] = len(tasks) + 1
19
+
20
+
21
+ def data_file() -> Path:
22
+ override = os.environ.get("TASK_MANAGER_DATA_DIR")
23
+ if override:
24
+ directory = Path(override)
25
+ elif sys.platform == "win32":
26
+ directory = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "task-manager"
27
+ elif sys.platform == "darwin":
28
+ directory = Path.home() / "Library" / "Application Support" / "task-manager"
29
+ else:
30
+ directory = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))) / "task-manager"
31
+ return directory / "tasks.json"
32
+
33
+
34
+ def load_data() -> dict:
35
+ path = data_file()
36
+ try:
37
+ if not path.exists() or path.stat().st_size == 0:
38
+ return {"next_id": 1, "tasks": []}
39
+ with path.open(encoding="utf-8") as file:
40
+ data = json.load(file)
41
+ except (OSError, UnicodeError) as error:
42
+ raise StorageError(f"Cannot read task file {path}: {error}") from error
43
+ except json.JSONDecodeError as error:
44
+ raise StorageError(f"Task file {path} contains invalid JSON: {error.msg}") from error
45
+
46
+ if (not isinstance(data, dict)
47
+ or type(data.get("next_id")) is not int
48
+ or data["next_id"] < 1
49
+ or not isinstance(data.get("tasks"), list)):
50
+ raise StorageError(f"Task file {path} has an invalid format.")
51
+ ids = set()
52
+ for task in data["tasks"]:
53
+ if (not isinstance(task, dict)
54
+ or type(task.get("id")) is not int
55
+ or task["id"] < 1
56
+ or task["id"] in ids
57
+ or not isinstance(task.get("title"), str)
58
+ or not task["title"].strip()
59
+ or task.get("status") not in ("pending", "completed")
60
+ or not isinstance(task.get("created_at"), str)):
61
+ raise StorageError(f"Task file {path} has an invalid format.")
62
+ ids.add(task["id"])
63
+ if ids and data["next_id"] <= max(ids):
64
+ raise StorageError(f"Task file {path} has an invalid format.")
65
+ normalize_data(data)
66
+ return data
67
+
68
+
69
+ def save_data(data: dict) -> None:
70
+ normalize_data(data)
71
+ path = data_file()
72
+ try:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ with path.open("w", encoding="utf-8") as file:
75
+ json.dump(data, file, indent=2, ensure_ascii=False)
76
+ file.write("\n")
77
+ except OSError as error:
78
+ raise StorageError(f"Cannot save task file {path}: {error}") from error
File without changes
@@ -0,0 +1,26 @@
1
+ import sys
2
+ import time
3
+
4
+ from .theme import ACCENT, animated, paint
5
+
6
+
7
+ def startup() -> None:
8
+ if not animated():
9
+ return
10
+ try:
11
+ sys.stdout.write("\x1b[?25l")
12
+ for frame in ("·", "✢", "✳", "✶", "✳", "✢"):
13
+ sys.stdout.write("\r " + paint(frame + " Opening your workspace", ACCENT))
14
+ sys.stdout.flush()
15
+ time.sleep(0.055)
16
+ finally:
17
+ sys.stdout.write("\r\x1b[2K\x1b[?25h")
18
+ sys.stdout.flush()
19
+
20
+
21
+ def reveal(lines: list[str]) -> None:
22
+ motion = animated()
23
+ for index, line in enumerate(lines):
24
+ print(line, flush=motion)
25
+ if motion and index < 10:
26
+ time.sleep(0.012)
@@ -0,0 +1,57 @@
1
+ import os
2
+ import shutil
3
+ import sys
4
+ import unicodedata
5
+
6
+ ACCENT = "\x1b[38;5;173m"
7
+ GREEN = "\x1b[38;5;114m"
8
+ MUTED = "\x1b[38;5;245m"
9
+ RED = "\x1b[38;5;203m"
10
+ BOLD = "\x1b[1m"
11
+ RESET = "\x1b[0m"
12
+
13
+
14
+ def styled() -> bool:
15
+ return sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
16
+
17
+
18
+ def animated() -> bool:
19
+ return styled() and not os.environ.get("TASK_MANAGER_NO_ANIMATION")
20
+
21
+
22
+ def paint(value: str, color: str) -> str:
23
+ return f"{color}{value}{RESET}" if color and styled() else value
24
+
25
+
26
+ def width() -> int:
27
+ return max(12, min(shutil.get_terminal_size((80, 24)).columns - 2, 96))
28
+
29
+
30
+ def clean(value: str) -> str:
31
+ return "".join(char if not unicodedata.category(char).startswith("C") else " " for char in value)
32
+
33
+
34
+ def cells(value: str) -> int:
35
+ return sum(0 if unicodedata.combining(char) else 2 if unicodedata.east_asian_width(char) in "WF" else 1 for char in value)
36
+
37
+
38
+ def fit(value: str, limit: int) -> str:
39
+ value = clean(value)
40
+ if cells(value) <= limit:
41
+ return value
42
+ result = ""
43
+ for char in value:
44
+ if cells(result + char) > limit - 1:
45
+ break
46
+ result += char
47
+ return result + "…"
48
+
49
+
50
+ def rule() -> None:
51
+ print(paint("─" * width(), MUTED))
52
+
53
+
54
+ def prompt() -> str:
55
+ if styled():
56
+ return f"\001{ACCENT}\002❯\001{RESET}\002 "
57
+ return "❯ "
@@ -0,0 +1,123 @@
1
+ import re
2
+
3
+ from .. import __version__
4
+ from ..productivity import weekly_counts
5
+ from ..storage import StorageError, load_data
6
+ from .animation import reveal
7
+ from .theme import ACCENT, BOLD, GREEN, MUTED, RED, cells, clean, fit, paint, width
8
+
9
+
10
+ HELP = (
11
+ ("add <title>", "Create a task"),
12
+ ("list / ls", "Show To do tasks"),
13
+ ("ls --done / --all", "Completed / every task"),
14
+ ("done / undone <id>", "Change status"),
15
+ ("update <id> <title>", "Rename a task"),
16
+ ("show <id>", "Task details"),
17
+ ("delete <id>", "Remove a task"),
18
+ ("stats", "Progress and weekly chart"),
19
+ ("demo", "Try a separate sample workspace"),
20
+ ("clear / cls", "Clear the terminal"),
21
+ ("clear-completed", "Remove completed tasks"),
22
+ ("task / home", "Show the welcome view"),
23
+ ("/exit", "Close this session"),
24
+ )
25
+
26
+
27
+ def welcome() -> None:
28
+ inner = width() - 2
29
+ label = fit(f" Task Manager · v{__version__} ", inner)
30
+ print()
31
+ print(paint("╭" + label + "─" * (inner - cells(label)) + "╮", ACCENT))
32
+
33
+ def padded(text: str, size: int, color: str = "") -> str:
34
+ text = fit(text, size)
35
+ return paint(text, color) + " " * (size - cells(text))
36
+
37
+ def row(left: str = "", right: str | None = None, color: str = "") -> None:
38
+ if right is None:
39
+ body = padded(left, inner, color)
40
+ else:
41
+ split = 33
42
+ body = padded(left, split, color) + paint("│", MUTED) + padded(right, inner - split - 2, color) + " "
43
+ print(paint("│", MUTED) + body + paint("│", MUTED))
44
+
45
+ try:
46
+ tasks = load_data()["tasks"]
47
+ done = sum(task["status"] == "completed" for task in tasks)
48
+ counts = [count for _, count in weekly_counts(tasks)]
49
+ peak = max(counts) or 1
50
+ spark = "".join("▁▂▃▄▅▆▇█"[round(count / peak * 7)] for count in counts)
51
+ next_task = next((task for task in tasks if task["status"] == "pending"), None)
52
+ next_title = f"#{next_task['id']} {next_task['title']}" if next_task else "All clear. Add your next task."
53
+ summary = f"{len(tasks) - done} to do / {done} completed"
54
+ if inner >= 72:
55
+ row("", "")
56
+ row(" Welcome back", " This week", BOLD)
57
+ row("", "")
58
+ row(" ▗▄▄▄▖", f" {spark} {sum(counts)} completed", ACCENT)
59
+ row(" ▐▪ ▪▌", " Last 7 days · stats for details", MUTED)
60
+ row(" ▝▀▀▀▘", "")
61
+ row("", " Up next", BOLD)
62
+ row(" " + summary, " " + next_title)
63
+ row(" Local workspace · Python CLI", " ls to view your tasks", MUTED)
64
+ row("", "")
65
+ else:
66
+ row()
67
+ row(" ✳ Welcome back", color=BOLD)
68
+ row(" Your tasks, one place.", color=MUTED)
69
+ row()
70
+ row(" " + summary)
71
+ row(f" {spark} {sum(counts)} this week", color=GREEN)
72
+ row(" Up next " + next_title, color=MUTED)
73
+ row()
74
+ except StorageError:
75
+ row(" Welcome back", color=BOLD)
76
+ row(" Tasks unavailable — check your data file", color=RED)
77
+ print(paint("╰" + "─" * inner + "╯", MUTED))
78
+ print(" " + paint(fit("/help commands · stats progress · /exit quit", width() - 2), MUTED))
79
+ print()
80
+
81
+
82
+ def show_help() -> None:
83
+ print(" " + paint("Commands", BOLD))
84
+ for command, hint in HELP:
85
+ if width() < 62:
86
+ print(" " + paint(command, ACCENT))
87
+ print(" " + paint(hint, MUTED))
88
+ else:
89
+ print(f" {paint(command.ljust(26), ACCENT)}{paint(hint, MUTED)}")
90
+ print("\n Commands accept a leading / or task prefix.")
91
+ print(" Tab completes commands; ↑ / ↓ recalls history where supported.")
92
+ print(" IDs change after edits. Use ls to see current IDs.")
93
+
94
+
95
+ def show_result(command: str, message: str, is_error: bool, args: list[str] | None = None) -> None:
96
+ if not message:
97
+ return
98
+ lines = []
99
+ if command in {"list", "ls"} and not is_error:
100
+ heading = "Completed" if args and "--done" in args else "All tasks" if args and "--all" in args else "To do"
101
+ lines.append(" " + paint(heading, BOLD))
102
+ count = 0
103
+ for line in message.splitlines():
104
+ match = re.fullmatch(r"(\d+)\. \[(pending|completed)\] (.*)", line)
105
+ if match:
106
+ task_id, status, title = match.groups()
107
+ done = status == "completed"
108
+ symbol = paint("✓", GREEN) if done else paint("○", ACCENT)
109
+ lines.append(f" {symbol} {paint(task_id.rjust(3), MUTED)} {paint(fit(title, width() - 11), MUTED) if done else fit(title, width() - 11)}")
110
+ count += 1
111
+ else:
112
+ lines.append(" " + paint(clean(line), MUTED))
113
+ if count:
114
+ lines += ["", " " + paint(f"{count} task(s) · done <id> to complete · stats to see progress", MUTED)]
115
+ elif command == "stats" and not is_error:
116
+ lines.append(" " + paint("Your momentum", BOLD))
117
+ for line in message.splitlines():
118
+ color = GREEN if "━" in line else MUTED if line.startswith("Chart") else ""
119
+ lines.append(" " + paint(line, color))
120
+ else:
121
+ marker = paint("✕", RED) if is_error else paint("●", GREEN)
122
+ lines = [f" {marker if index == 0 else ' '} {clean(line)}" for index, line in enumerate(message.splitlines())]
123
+ reveal(lines)