Add README, new_cycle, weekly_review, and update habits.sh
This commit is contained in:
parent
b5b58c8b90
commit
fa9b90ae1b
4 changed files with 507 additions and 16 deletions
270
weekly_review
Executable file
270
weekly_review
Executable file
|
|
@ -0,0 +1,270 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
H1_RE = re.compile(r"^# (?!#)")
|
||||
DATE_H1_RE = re.compile(r"^# (\d{4}-\d{2}-\d{2})\s*$")
|
||||
CYCLE_RE = re.compile(r"^# Cycle \d+ \((\d{2}/\d{2}/\d{4}) - (\d{2}/\d{2}/\d{4})\)\s*$")
|
||||
CHECK_RE = re.compile(r"^- \[( |x|X)\] (.+)$")
|
||||
METRIC_RE = re.compile(r"^-?\s*([^:]+):\s*(.*)$")
|
||||
|
||||
|
||||
# Edit this markdown to change the weekly review template appended to each file.
|
||||
REVIEW_TEMPLATE = """## Accomplishments
|
||||
|
||||
## Disapointments
|
||||
|
||||
## Highs
|
||||
|
||||
## Lows
|
||||
|
||||
## What I learned
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def parse_iso(s: str) -> date:
|
||||
return datetime.strptime(s, "%Y-%m-%d").date()
|
||||
|
||||
|
||||
def parse_us(s: str) -> date:
|
||||
return datetime.strptime(s, "%m/%d/%Y").date()
|
||||
|
||||
|
||||
def fmt_us(d: date) -> str:
|
||||
return d.strftime("%m/%d/%Y")
|
||||
|
||||
|
||||
def fmt_file(d: date) -> str:
|
||||
return d.strftime("%m-%d-%Y")
|
||||
|
||||
|
||||
def last_completed_sun_sat(today: date) -> tuple[date, date]:
|
||||
days_since_saturday = (today.weekday() + 2) % 7
|
||||
end = today - timedelta(days=days_since_saturday)
|
||||
start = end - timedelta(days=6)
|
||||
return start, end
|
||||
|
||||
|
||||
def dates_between(start: date, end: date) -> list[date]:
|
||||
days = []
|
||||
current = start
|
||||
while current <= end:
|
||||
days.append(current)
|
||||
current += timedelta(days=1)
|
||||
return days
|
||||
|
||||
|
||||
def section_lines(lines: list[str], header_index: int) -> list[str]:
|
||||
end = len(lines)
|
||||
for i in range(header_index + 1, len(lines)):
|
||||
if H1_RE.match(lines[i]):
|
||||
end = i
|
||||
break
|
||||
return lines[header_index + 1 : end]
|
||||
|
||||
|
||||
def parse_habits(path: Path, start: date, end: date):
|
||||
entries = {d: {"habits": {}, "metrics": {}} for d in dates_between(start, end)}
|
||||
habit_names = []
|
||||
metric_names = []
|
||||
|
||||
if not path.exists():
|
||||
return entries, habit_names, metric_names
|
||||
|
||||
lines = path.read_text().splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
m = DATE_H1_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
day = parse_iso(m.group(1))
|
||||
if day < start or day > end:
|
||||
continue
|
||||
|
||||
current_section = None
|
||||
for entry_line in section_lines(lines, i):
|
||||
stripped = entry_line.strip()
|
||||
if stripped == "## Habits":
|
||||
current_section = "habits"
|
||||
continue
|
||||
if stripped == "## Metrics":
|
||||
current_section = "metrics"
|
||||
continue
|
||||
if stripped.startswith("## "):
|
||||
current_section = None
|
||||
continue
|
||||
|
||||
if current_section == "habits":
|
||||
hm = CHECK_RE.match(stripped)
|
||||
if hm:
|
||||
name = hm.group(2).strip()
|
||||
entries[day]["habits"][name] = hm.group(1).lower() == "x"
|
||||
if name not in habit_names:
|
||||
habit_names.append(name)
|
||||
elif current_section == "metrics":
|
||||
mm = METRIC_RE.match(stripped)
|
||||
if mm:
|
||||
name = mm.group(1).strip()
|
||||
value = mm.group(2).strip()
|
||||
entries[day]["metrics"][name] = value
|
||||
if name not in metric_names:
|
||||
metric_names.append(name)
|
||||
|
||||
return entries, habit_names, metric_names
|
||||
|
||||
|
||||
def extract_done_tasks(path: Path, start: date, end: date) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
|
||||
lines = path.read_text().splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
m = CYCLE_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
if parse_us(m.group(1)) == start and parse_us(m.group(2)) == end:
|
||||
body = section_lines(lines, i)
|
||||
while body and not body[0].strip():
|
||||
body.pop(0)
|
||||
while body and not body[-1].strip():
|
||||
body.pop()
|
||||
if body and body[0].strip() == "## Done":
|
||||
body = body[1:]
|
||||
while body and not body[0].strip():
|
||||
body.pop(0)
|
||||
return body
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def markdown_table(headers: list[str], rows: list[list[str]]) -> str:
|
||||
table = [headers] + rows
|
||||
widths = [max(len(str(row[i])) for row in table) for i in range(len(headers))]
|
||||
|
||||
def fmt_row(row: list[str]) -> str:
|
||||
cells = [str(cell).ljust(widths[i]) for i, cell in enumerate(row)]
|
||||
return "| " + " | ".join(cells) + " |"
|
||||
|
||||
separator = ["-" * width for width in widths]
|
||||
return (
|
||||
"\n".join(
|
||||
[fmt_row(headers), fmt_row(separator), *[fmt_row(row) for row in rows]]
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def build_habit_chart(entries, habit_names: list[str], start: date, end: date) -> str:
|
||||
days = dates_between(start, end)
|
||||
headers = ["Habit"] + [d.strftime("%a %m/%d") for d in days] + ["Total"]
|
||||
rows = []
|
||||
for habit in habit_names:
|
||||
total = 0
|
||||
row = [habit]
|
||||
for day in days:
|
||||
value = entries[day]["habits"].get(habit)
|
||||
if value is True:
|
||||
row.append("✓")
|
||||
total += 1
|
||||
elif value is False:
|
||||
row.append("—")
|
||||
else:
|
||||
row.append("")
|
||||
row.append(f"{total}/7")
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
return "No habit entries found for this week.\n"
|
||||
return markdown_table(headers, rows)
|
||||
|
||||
|
||||
def build_metrics_chart(
|
||||
entries, metric_names: list[str], start: date, end: date
|
||||
) -> str:
|
||||
days = dates_between(start, end)
|
||||
headers = ["Metric"] + [d.strftime("%a %m/%d") for d in days]
|
||||
rows = []
|
||||
for metric in metric_names:
|
||||
rows.append(
|
||||
[metric] + [entries[day]["metrics"].get(metric, "") for day in days]
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return "No metrics found for this week.\n"
|
||||
return markdown_table(headers, rows)
|
||||
|
||||
|
||||
def load_review_markdown() -> str:
|
||||
return REVIEW_TEMPLATE.rstrip() + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create a weekly review markdown file."
|
||||
)
|
||||
parser.add_argument("--today", help="Override today, format YYYY-MM-DD")
|
||||
parser.add_argument("--start", help="Override week start, format YYYY-MM-DD")
|
||||
parser.add_argument("--end", help="Override week end, format YYYY-MM-DD")
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Overwrite an existing review file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-open", action="store_true", help="Do not open the review in $EDITOR"
|
||||
)
|
||||
parser.add_argument("--output-dir", default="~/.local/share/os/weeks")
|
||||
parser.add_argument("--habits-file", default="~/.local/share/os/habits.md")
|
||||
parser.add_argument("--tasks-file", default="~/.local/share/os/tasks.md")
|
||||
args = parser.parse_args()
|
||||
|
||||
today = parse_iso(args.today) if args.today else date.today()
|
||||
if args.start or args.end:
|
||||
if not args.start or not args.end:
|
||||
raise SystemExit("Use --start and --end together")
|
||||
start = parse_iso(args.start)
|
||||
end = parse_iso(args.end)
|
||||
else:
|
||||
start, end = last_completed_sun_sat(today)
|
||||
|
||||
output_dir = Path(args.output_dir).expanduser()
|
||||
output_path = output_dir / f"{fmt_file(start)}--{fmt_file(end)}.md"
|
||||
if output_path.exists() and not args.force:
|
||||
print(f"Review already exists: {output_path}")
|
||||
return
|
||||
|
||||
habits_file = Path(args.habits_file).expanduser()
|
||||
tasks_file = Path(args.tasks_file).expanduser()
|
||||
|
||||
habit_entries, habit_names, metric_names = parse_habits(habits_file, start, end)
|
||||
done_tasks = extract_done_tasks(tasks_file, start, end)
|
||||
review_markdown = load_review_markdown()
|
||||
|
||||
content = []
|
||||
content.append(f"# Weekly Review ({fmt_us(start)} - {fmt_us(end)})\n\n")
|
||||
content.append("## Habits\n\n")
|
||||
content.append(build_habit_chart(habit_entries, habit_names, start, end))
|
||||
content.append("\n## Metrics\n\n")
|
||||
content.append(build_metrics_chart(habit_entries, metric_names, start, end))
|
||||
content.append("\n## Done Tasks\n\n")
|
||||
if done_tasks:
|
||||
content.append("\n".join(done_tasks).rstrip() + "\n")
|
||||
else:
|
||||
content.append("No done tasks found for this week.\n")
|
||||
content.append("\n")
|
||||
content.append(review_markdown)
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text("".join(content))
|
||||
print(f"Created {output_path}")
|
||||
|
||||
if not args.no_open:
|
||||
subprocess.run([os.environ.get("EDITOR", "vi"), str(output_path)], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue