From fa9b90ae1bc5cd923344e709a0ee60507496318a Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Fri, 24 Jul 2026 10:00:31 -0400 Subject: [PATCH] Add README, new_cycle, weekly_review, and update habits.sh --- README.md | 19 ++++ habits.sh | 71 ++++++++++--- new_cycle | 163 ++++++++++++++++++++++++++++++ weekly_review | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 README.md create mode 100755 new_cycle create mode 100755 weekly_review diff --git a/README.md b/README.md new file mode 100644 index 0000000..328d497 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# OS + +This is a set of productivity scripts bash/python to create a "life operating system." +Inspired mainly by August Bradley's Notion PPV system (which I previously built inside of Anytype). +The issue with Anytype (and Notion) is that there are too many clicks needed. +While Notion has better automation, I think nothing can beat the functionality of just scripts. +That and the fact that I want to have all the files as markdown in case I want to search/collect statistics or plots/process with a local llm inspired this project. + +## Functionality and status + +Here is the list of functionality that I'm working towards. + +- [x] Cycles: Basic task weekly task log with history stored in a task.md file. +- [x] Habits: Keep track of habits and metrics in a habits.md file +- [x] Weekly Review: Review habits/completed actions and write some notes for each week. +- [ ] Monthly Review: Review habits/completed actions/and weekly accomplishments and update plans for each month. +- [ ] Vault: Knowledge vault for tracking notes +- [ ] Projects: Project management file that can be synced to pull tasks associated with the project and links to notes from vaults. +- [ ] Daily Mindset Shaping: Generates a view into a daily routine for mindset shaping. diff --git a/habits.sh b/habits.sh index 3ae7be7..f3acfc9 100755 --- a/habits.sh +++ b/habits.sh @@ -19,41 +19,79 @@ ensure_file() { touch "$HABITS_FILE" } +date_add_days() { + local day="$1" offset="$2" date_arg epoch + + date_arg="$day $offset days" + if date -d "$date_arg" +%F >/dev/null 2>&1; then + date -d "$date_arg" +%F + return + fi + + # BSD/macOS date. Parse at noon to avoid midnight DST edges. + epoch="$(date -j -f '%F %H:%M:%S' "$day 12:00:00" +%s)" + epoch=$((epoch + offset * 86400)) + date -r "$epoch" +%F +} + open_habits() { ensure_file "${EDITOR:-vi}" "$HABITS_FILE" } +print_day() { + local day="$1" + + printf '# %s\n\n' "$day" + printf '## Habits\n\n' + while IFS= read -r habit; do + [ -n "$habit" ] && printf -- '- [ ] %s\n' "$habit" + done <<< "$HABITS" + printf '\n## Metrics \n\n' + while IFS= read -r metric; do + [ -n "$metric" ] && printf -- '- %s\n' "$metric:" + done <<< "$METRICS" + printf '\n' +} + new_day() { ensure_file - local date - date="$(date +%F)" + local today latest start day tmp added + today="$(date +%F)" + latest="$(grep -E '^# [0-9]{4}-[0-9]{2}-[0-9]{2}$' "$HABITS_FILE" | sed 's/^# //' | sort | tail -n 1 || true)" - if grep -q "^# $date$" "$HABITS_FILE"; then - echo "A habits entry for $date already exists: $HABITS_FILE" >&2 + if [ -n "$latest" ] && [[ "$latest" > "$today" ]]; then + echo "Latest habits entry is in the future: $latest" >&2 exit 1 fi - local tmp + if [ "$latest" = "$today" ]; then + echo "Habits entries already exist through $today: $HABITS_FILE" + return + fi + + if [ -n "$latest" ]; then + start="$(date_add_days "$latest" 1)" + else + start="$today" + fi + tmp="$(mktemp)" + added=0 { - printf '# %s\n\n' "$date" - printf '## Habits\n\n' - while IFS= read -r habit; do - [ -n "$habit" ] && printf -- '- [ ] %s\n' "$habit" - done <<< "$HABITS" - printf '\n## Metrics \n\n' - while IFS= read -r metric; do - [ -n "$metric" ] && printf -- '- %s\n' "$metric" ":" - done <<< "$METRICS" - printf '\n' + day="$today" + while [[ "$day" > "$start" || "$day" = "$start" ]]; do + print_day "$day" + added=$((added + 1)) + day="$(date_add_days "$day" -1)" + done cat "$HABITS_FILE" } > "$tmp" mv "$tmp" "$HABITS_FILE" - echo "Added $date to $HABITS_FILE" + echo "Added $added habits entr$( [ "$added" -eq 1 ] && printf 'y' || printf 'ies' ) through $today to $HABITS_FILE" } remaining_habits() { @@ -80,6 +118,7 @@ case "${1:-}" in ;; new) new_day + open_habits ;; left) remaining_habits diff --git a/new_cycle b/new_cycle new file mode 100755 index 0000000..c99b0bc --- /dev/null +++ b/new_cycle @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +import argparse +import re +from datetime import date, datetime, timedelta +from pathlib import Path + + +H1_RE = re.compile(r"^# (?!#)") +CYCLE_RE = re.compile( + r"^# Cycle (\d+) \((\d{2}/\d{2}/\d{4}) - (\d{2}/\d{2}/\d{4})\)$" +) + + +def previous_sun_sat(today: date): + # End on the most recent Saturday. + days_since_saturday = (today.weekday() + 2) % 7 + end = today - timedelta(days=days_since_saturday) + start = end - timedelta(days=6) + return start, end + + +def fmt(d: date): + return d.strftime("%m/%d/%Y") + + +def parse_date(s: str): + return datetime.strptime(s, "%m/%d/%Y").date() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "file", + nargs="?", + default="~/.local/share/os/tasks.md", + help="Task markdown file (default: ~/.local/share/os/tasks.md)", + ) + parser.add_argument("--today", help="Override today, format YYYY-MM-DD") + args = parser.parse_args() + + path = Path(args.file).expanduser() + lines = path.read_text().splitlines(keepends=True) + + today = ( + datetime.strptime(args.today, "%Y-%m-%d").date() + if args.today + else date.today() + ) + + target_start, target_end = previous_sun_sat(today) + + cycles = [] + for line in lines: + m = CYCLE_RE.match(line.strip()) + if m: + cycles.append((int(m.group(1)), parse_date(m.group(2)), parse_date(m.group(3)))) + + max_cycle = max((cycle[0] for cycle in cycles), default=0) + latest_end = max((cycle[2] for cycle in cycles), default=None) + + if latest_end is not None and latest_end >= target_end: + print(f"Nothing to do; latest cycle already ends {fmt(latest_end)}") + return + + missing_cycles = [] + if latest_end is None: + missing_cycles.append((max_cycle + 1, target_start, target_end)) + else: + cycle_start = latest_end + timedelta(days=1) + cycle_num = max_cycle + 1 + while cycle_start + timedelta(days=6) <= target_end: + cycle_end = cycle_start + timedelta(days=6) + missing_cycles.append((cycle_num, cycle_start, cycle_end)) + cycle_start += timedelta(days=7) + cycle_num += 1 + + if not missing_cycles: + print("Nothing to do") + return + + current_start = None + for i, line in enumerate(lines): + if line.strip() == "# Current": + current_start = i + break + + if current_start is None: + raise SystemExit("Could not find '# Current' section") + + current_end = len(lines) + for i in range(current_start + 1, len(lines)): + if H1_RE.match(lines[i]): + current_end = i + break + + done_header = None + for i in range(current_start + 1, current_end): + if lines[i].strip() == "## Done": + done_header = i + break + + if done_header is None: + raise SystemExit("Could not find '## Done' under '# Current'") + + done_start = done_header + 1 + done_end = current_end + for i in range(done_start, current_end): + if lines[i].startswith("## "): + done_end = i + break + + done_lines = lines[done_start:done_end] + + while done_lines and not done_lines[0].strip(): + done_lines.pop(0) + while done_lines and not done_lines[-1].strip(): + done_lines.pop() + + # Empty Current -> Done section. If there are no done items, still create + # the new cycle block with an empty Done section. + lines = lines[:done_start] + ["\n"] + lines[done_end:] + + # Re-find end of Current after edit. + current_start = next(i for i, line in enumerate(lines) if line.strip() == "# Current") + insert_at = len(lines) + for i in range(current_start + 1, len(lines)): + if H1_RE.match(lines[i]): + insert_at = i + break + + cycle_block = [] + latest_cycle = missing_cycles[-1] + for cycle_num, cycle_start, cycle_end in reversed(missing_cycles): + cycle_block.extend([ + f"# Cycle {cycle_num} ({fmt(cycle_start)} - {fmt(cycle_end)})\n", + "\n", + ]) + if (cycle_num, cycle_start, cycle_end) == latest_cycle: + cycle_block.extend(done_lines) + cycle_block.append("\n") + + prefix = lines[:insert_at] + suffix = lines[insert_at:] + + if prefix and prefix[-1].strip(): + prefix.append("\n") + + if suffix and suffix[0].strip(): + cycle_block.append("\n") + + path.write_text("".join(prefix + cycle_block + suffix)) + + if len(missing_cycles) == 1: + cycle_num, cycle_start, cycle_end = missing_cycles[0] + print(f"Created # Cycle {cycle_num} ({fmt(cycle_start)} - {fmt(cycle_end)})") + else: + first_num = missing_cycles[0][0] + last_num = missing_cycles[-1][0] + print(f"Created cycles {first_num}-{last_num}") + + +if __name__ == "__main__": + main() diff --git a/weekly_review b/weekly_review new file mode 100755 index 0000000..dc35ce4 --- /dev/null +++ b/weekly_review @@ -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()