Skip to content

Introduce interactive TUI Weekly Editor (punch weeklyeditor)#22

Open
jasimioni wants to merge 3 commits intodargad:mainfrom
jasimioni:weeklyview
Open

Introduce interactive TUI Weekly Editor (punch weeklyeditor)#22
jasimioni wants to merge 3 commits intodargad:mainfrom
jasimioni:weeklyview

Conversation

@jasimioni
Copy link
Copy Markdown

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 local tasks.txt file.

Key Features

  • Interactive Weekly Grid: Displays a 7-day column view (Sun-Sat). Users can navigate between days and individual time cards using the arrow keys.
  • Infinite Week Scrolling: Pressing Left on Sunday or Right on Saturday automatically shifts the UI to the previous or next week, dynamically reloading the respective data.
  • Smart Task Entry Modal: * Accessed via a (Add), e (Edit), or Enter.
    • Category Selection: Scrollable list populated directly from the user's ~/.config/punch/punch.yaml.
    • Duration & Description: Inputs for task duration (in minutes) and an optional description.
    • Dynamic Suggestions: As a category is highlighted, a secondary list automatically populates with historically used descriptions for that specific category. Pressing Enter on a suggestion auto-fills the description field.
  • Automated Timeline Calculation: The engine parses ~/.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 standard 08:30 start time for each day.
  • Auto-Saving: Any addition, modification, or deletion instantly recalculates the daily/weekly totals and writes the updated timestamps back to disk. It safely preserves all historical data for dates outside the currently viewed week.

Documentation

  • Updated README.md with instructions on how to use punch weeklyeditor, detailing the keyboard shortcuts, smart features, and expected workflow (reminding users that punch submit is still required to officially log time).

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/saves tasks.txt.
  • Adds a weeklyeditor CLI 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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
import punch.commands

Copilot uses AI. Check for mistakes.
Comment on lines +337 to +354
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)

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.

def clear_entries(self):
container = self.query_one(".cards-container")
for child in container.children:
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
for child in container.children:
for child in list(container.children):

Copilot uses AI. Check for mistakes.
Comment on lines +357 to +363
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]

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +430 to +439
@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()
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +363
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)

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +27
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 {}
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 174 to 183
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]
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
**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.

Copilot uses AI. Check for mistakes.
Comment on lines +293 to +369
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")

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants