Introduce interactive TUI Weekly Editor (punch weeklyeditor)#22
Introduce interactive TUI Weekly Editor (punch weeklyeditor)#22jasimioni wants to merge 3 commits intodargad:mainfrom
Conversation
Original behavior is to raise exception. Now a warning is printed. During submission, one additional table is printed, listing these cases and letting the user know they won't be submitted. web.py: change the behavior commands.py: prints the additional table when needed
weeklyeditor.py: main code cli.py: hook to allow calling `punch weeklyeditor` to start it This mode is a calendar like view of timecards, allowing the user to see the entire week to add and remove tasks.
There was a problem hiding this comment.
Pull request overview
This PR adds a new interactive Textual-based weekly editor (punch weeklyeditor) for viewing and editing a full week of tasks, and adjusts submission behavior to handle entries without case mappings more gracefully.
Changes:
- Introduces
punch/weeklyeditor.py, an interactive weekly TUI that loads/savestasks.txt. - Adds a
weeklyeditorCLI command entry point. - Updates submission flow to display and skip entries missing case numbers rather than failing.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| punch/weeklyeditor.py | New Textual TUI for weekly task editing with autosave to tasks.txt. |
| punch/ui/cli.py | Adds punch weeklyeditor command to launch the TUI. |
| punch/web.py | Adjusts timecard conversion behavior (no longer hard-fails on missing case mapping) and adds an import. |
| punch/commands.py | Allows submission to proceed while separating out entries missing case numbers. |
| README.md | Documents the weekly editor usage and keyboard shortcuts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import time | ||
| from playwright.sync_api import sync_playwright, Error as playwright_error | ||
| from punch.tasks import read_tasklog | ||
| import punch.commands |
There was a problem hiding this comment.
punch/web.py now imports punch.commands at module import time, but punch/commands.py already imports from punch.web. This introduces a circular import that can break CLI startup (partially-initialized modules). Remove this import (it also appears unused) or move any required dependency into a function scope to avoid the cycle.
| import punch.commands |
| if os.path.exists(self.tasks_file): | ||
| with open(self.tasks_file, 'r') as f: | ||
| for line in f: | ||
| parts = line.split('|') | ||
| if parts: | ||
| if parts[0].strip()[:10] not in visible_dates: | ||
| preserved_lines.append(line.strip()) | ||
|
|
||
| new_lines = [] | ||
| for col in columns: | ||
| cards = col.query(TimeCard) | ||
| if not cards: continue | ||
| date_str = col.date_obj.strftime("%Y-%m-%d") | ||
| current_time = datetime.strptime(f"{date_str} {DEFAULT_START_TIME}", "%Y-%m-%d %H:%M") | ||
| new_lines.append(f"{current_time.strftime('%Y-%m-%d %H:%M')} | start") | ||
| for card in cards: | ||
| current_time += timedelta(minutes=card.minutes) | ||
|
|
There was a problem hiding this comment.
save_data() always writes a start line at DEFAULT_START_TIME for each non-empty day, which can overwrite an existing day’s actual start timestamp (e.g., when the user used punch start -t ...). This changes historical data and can shift all subsequent timestamps. Consider preserving the original per-day start timestamp when present, or using the configured start_time consistently.
| if os.path.exists(self.tasks_file): | |
| with open(self.tasks_file, 'r') as f: | |
| for line in f: | |
| parts = line.split('|') | |
| if parts: | |
| if parts[0].strip()[:10] not in visible_dates: | |
| preserved_lines.append(line.strip()) | |
| new_lines = [] | |
| for col in columns: | |
| cards = col.query(TimeCard) | |
| if not cards: continue | |
| date_str = col.date_obj.strftime("%Y-%m-%d") | |
| current_time = datetime.strptime(f"{date_str} {DEFAULT_START_TIME}", "%Y-%m-%d %H:%M") | |
| new_lines.append(f"{current_time.strftime('%Y-%m-%d %H:%M')} | start") | |
| for card in cards: | |
| current_time += timedelta(minutes=card.minutes) | |
| original_start_times = {} | |
| if os.path.exists(self.tasks_file): | |
| with open(self.tasks_file, 'r') as f: | |
| for line in f: | |
| parts = line.split('|') | |
| if not parts or not parts[0].strip(): | |
| continue | |
| date_time_str = parts[0].strip() | |
| date_key = date_time_str[:10] | |
| # Record earliest timestamp per visible date before discarding its lines | |
| if date_key in visible_dates: | |
| try: | |
| dt_obj = datetime.strptime(date_time_str, "%Y-%m-%d %H:%M") | |
| except ValueError: | |
| dt_obj = None | |
| if dt_obj is not None: | |
| if date_key not in original_start_times or dt_obj < original_start_times[date_key]: | |
| original_start_times[date_key] = dt_obj | |
| else: | |
| preserved_lines.append(line.strip()) | |
| new_lines = [] | |
| for col in columns: | |
| cards = col.query(TimeCard) | |
| if not cards: continue | |
| date_str = col.date_obj.strftime("%Y-%m-%d") | |
| # Use the original start time for this date if available; otherwise fall back to DEFAULT_START_TIME | |
| if date_str in original_start_times: | |
| current_time = original_start_times[date_str] | |
| else: | |
| current_time = datetime.strptime(f"{date_str} {DEFAULT_START_TIME}", "%Y-%m-%d %H:%M") | |
| new_lines.append(f"{current_time.strftime('%Y-%m-%d %H:%M')} | start") | |
| for card in cards: | |
| current_time += timedelta(minutes=card.minutes) |
|
|
||
| def clear_entries(self): | ||
| container = self.query_one(".cards-container") | ||
| for child in container.children: |
There was a problem hiding this comment.
DayColumn.clear_entries() removes children while iterating for child in container.children:. Mutating the underlying child list during iteration can skip elements, leaving stale TimeCards behind when switching weeks. Iterate over a copy (e.g., for child in list(container.children):) before removing.
| for child in container.children: | |
| for child in list(container.children): |
| no_case_entries = [tc for tc in timecards if getattr(tc, "case_no") is None] | ||
|
|
||
| if no_case_entries: | ||
| show_timecards_table(no_case_entries, title="Entries with missing case numbers (won't be submitted)") | ||
|
|
||
| timecards = [tc for tc in timecards if getattr(tc, "case_no") is not None] | ||
|
|
There was a problem hiding this comment.
Now that submission filters timecards by case_no is None/is not None, the earlier except NoCaseMappingError branch in this function becomes misleading (and likely unreachable, since _convert_to_timecard no longer raises it). Consider removing the exception type (and its import) or reintroducing the exception, so error-handling matches the new behavior.
| @app.command("weeklyeditor") | ||
| def weeklyeditor( | ||
| verbose: bool = typer.Option(False, "-v", "--verbose", help="Enable verbose output"), | ||
| ): | ||
| """ | ||
| Run the weekly editor of tasks. | ||
| """ | ||
| from punch.weeklyeditor import WeeklyEditorApp | ||
| app = WeeklyEditorApp(config_file=get_config_path(), tasks_file=get_tasks_file()) | ||
| app.run() |
There was a problem hiding this comment.
The newly added weeklyeditor command defines a verbose option but doesn’t use it (and doesn’t pass it into the TUI app). Either wire it up (e.g., enable debug/logging) or remove the option to avoid implying supported behavior that isn’t implemented.
| new_lines = [] | ||
| for col in columns: | ||
| cards = col.query(TimeCard) | ||
| if not cards: continue | ||
| date_str = col.date_obj.strftime("%Y-%m-%d") | ||
| current_time = datetime.strptime(f"{date_str} {DEFAULT_START_TIME}", "%Y-%m-%d %H:%M") | ||
| new_lines.append(f"{current_time.strftime('%Y-%m-%d %H:%M')} | start") | ||
| for card in cards: | ||
| current_time += timedelta(minutes=card.minutes) | ||
|
|
||
| line = f"{current_time.strftime('%Y-%m-%d %H:%M')} | {card.category} | " | ||
| if card.description: | ||
| line += f"{card.description}" | ||
|
|
||
| if card.description: | ||
| if card.category not in self.description_history: self.description_history[card.category] = set() | ||
| self.description_history[card.category].add(card.description) | ||
| new_lines.append(line) | ||
|
|
There was a problem hiding this comment.
Saving rewrites each visible-day entry as timestamp | category | <description> and ignores any existing 4th field (notes). If a user previously logged entries with notes (supported by punch.tasks.parse_task / write_task), opening and saving in the weekly editor will silently drop those notes for the edited week. Please preserve the notes field (e.g., add a notes input, or carry forward existing notes when rewriting).
| def parse_config(config_file): | ||
| if not os.path.exists(config_file): | ||
| return {} | ||
| try: | ||
| with open(config_file, 'r') as f: | ||
| data = yaml.safe_load(f) | ||
| return data | ||
| except: | ||
| return {} |
There was a problem hiding this comment.
parse_config() uses a bare except: and returns {} on any error, which will silently hide YAML parse errors and other unexpected issues. Catch a narrower exception (e.g., OSError, yaml.YAMLError) and consider surfacing an error/notification so users know their config is invalid.
| def get_timecards(config, file_path="tasks.txt", date_from=None, date_to=None): | ||
| """ | ||
| Returns a list of TimecardEntry objects for tasks between date_from and date_to (inclusive). | ||
| date_from and date_to should be datetime.date objects or None (defaults to all). | ||
| """ | ||
| entries = _get_valid_entries(file_path, date_from, date_to) | ||
| if not entries: | ||
| return [] | ||
|
|
||
| return [_convert_to_timecard(config, entry) for entry in entries] |
There was a problem hiding this comment.
After removing the missing-case exception, _convert_to_timecard() can return TimecardEntry objects with case_no=None, and callers now filter on case_no is None. It would be clearer/safer to reflect this in the TimecardEntry type (e.g., Optional[str]) and/or in get_timecards()’s docstring so downstream code doesn’t assume case_no is always a string.
|
|
||
| You do not need to manually save your week. Every time you add, edit, or delete a task, the editor automatically recalculates the timeline and safely rewrites the changes to `~/.local/share/punch/tasks.txt`, preserving your history for dates outside the current view. | ||
|
|
||
| **Note:** The weekly editor only modifies your local `tasks.txt` file. After you are finished editing your tasks for the week, the regular `punch submit` commands still need to be issued to officially log your time.### Weekly Editor |
There was a problem hiding this comment.
There’s a markdown formatting issue at the end of this section: the note line ends with ...officially log your time.### Weekly Editor, which looks like an accidental duplicate heading and is missing a newline. Please remove the stray ### Weekly Editor (or add the intended newline/spacing) so the README renders correctly.
| **Note:** The weekly editor only modifies your local `tasks.txt` file. After you are finished editing your tasks for the week, the regular `punch submit` commands still need to be issued to officially log your time.### Weekly Editor | |
| **Note:** The weekly editor only modifies your local `tasks.txt` file. After you are finished editing your tasks for the week, the regular `punch submit` commands still need to be issued to officially log your time. |
| def load_data(self): | ||
| if not os.path.exists(self.tasks_file): return | ||
| with open(self.tasks_file, 'r') as f: lines = f.readlines() | ||
|
|
||
| data_by_date = {} | ||
| self.description_history = {} | ||
|
|
||
| for line in lines: | ||
| parts = line.strip().split('|') | ||
| if len(parts) < 2: continue | ||
| try: | ||
| dt_obj = datetime.strptime(parts[0].strip(), "%Y-%m-%d %H:%M") | ||
| date_key = dt_obj.strftime("%Y-%m-%d") | ||
| except ValueError: continue | ||
|
|
||
| cat = parts[1].strip() | ||
| desc = parts[2].strip() if len(parts) > 2 else "" | ||
|
|
||
| if cat not in self.description_history: self.description_history[cat] = set() | ||
| if desc: self.description_history[cat].add(desc) | ||
|
|
||
| if date_key not in data_by_date: data_by_date[date_key] = [] | ||
| data_by_date[date_key].append({"dt": dt_obj, "cat": cat, "desc": desc}) | ||
|
|
||
| for col in self.query(DayColumn): | ||
| col_date_str = col.date_obj.strftime("%Y-%m-%d") | ||
| if col_date_str in data_by_date: | ||
| entries = sorted(data_by_date[col_date_str], key=lambda x: x["dt"]) | ||
| last_time = datetime.strptime(f"{col_date_str} {DEFAULT_START_TIME}", "%Y-%m-%d %H:%M") | ||
| for entry in entries: | ||
| if entry["cat"].lower() == "start": | ||
| last_time = entry["dt"] | ||
| continue | ||
| duration = int((entry["dt"] - last_time).total_seconds() / 60) | ||
| if duration > 0: col.add_entry(entry["cat"], duration, entry["desc"]) | ||
| last_time = entry["dt"] | ||
| col.update_total() | ||
|
|
||
| def save_data(self): | ||
| visible_dates = set() | ||
| columns = self.query(DayColumn) | ||
| for col in columns: visible_dates.add(col.date_obj.strftime("%Y-%m-%d")) | ||
|
|
||
| preserved_lines = [] | ||
| if os.path.exists(self.tasks_file): | ||
| with open(self.tasks_file, 'r') as f: | ||
| for line in f: | ||
| parts = line.split('|') | ||
| if parts: | ||
| if parts[0].strip()[:10] not in visible_dates: | ||
| preserved_lines.append(line.strip()) | ||
|
|
||
| new_lines = [] | ||
| for col in columns: | ||
| cards = col.query(TimeCard) | ||
| if not cards: continue | ||
| date_str = col.date_obj.strftime("%Y-%m-%d") | ||
| current_time = datetime.strptime(f"{date_str} {DEFAULT_START_TIME}", "%Y-%m-%d %H:%M") | ||
| new_lines.append(f"{current_time.strftime('%Y-%m-%d %H:%M')} | start") | ||
| for card in cards: | ||
| current_time += timedelta(minutes=card.minutes) | ||
|
|
||
| line = f"{current_time.strftime('%Y-%m-%d %H:%M')} | {card.category} | " | ||
| if card.description: | ||
| line += f"{card.description}" | ||
|
|
||
| if card.description: | ||
| if card.category not in self.description_history: self.description_history[card.category] = set() | ||
| self.description_history[card.category].add(card.description) | ||
| new_lines.append(line) | ||
|
|
||
| all_lines = preserved_lines + new_lines | ||
| all_lines.sort() | ||
| os.makedirs(os.path.dirname(self.tasks_file), exist_ok=True) | ||
| with open(self.tasks_file, 'w') as f: | ||
| for line in all_lines: f.write(line + "\n") | ||
|
|
There was a problem hiding this comment.
The weekly editor introduces non-trivial parsing/rewriting of tasks.txt (including preserving non-visible dates and recomputing timestamps). The repo already has unit tests for task parsing and web submission; adding tests around WeeklyEditorApp.load_data() / save_data() (especially note preservation and start-time handling) would help prevent data-loss regressions.
Overview This PR introduces the
weeklyeditor, a fully interactive Terminal User Interface (TUI) built with the Textual framework. It allows users to view, add, edit, and delete time cards for an entire week at a glance, acting as a visual front-end that seamlessly syncs with the localtasks.txtfile.Key Features
a(Add),e(Edit), orEnter.~/.config/punch/punch.yaml.Enteron a suggestion auto-fills the description field.~/.local/share/punch/tasks.txt(YYYY-MM-DD HH:MM | Category | Description). It calculates individual card durations based on the time deltas between tasks, assuming a standard08:30start time for each day.Documentation
README.mdwith instructions on how to usepunch weeklyeditor, detailing the keyboard shortcuts, smart features, and expected workflow (reminding users thatpunch submitis still required to officially log time).