From b8179add9948c4989b1ff1401abecad3c5dccd2f Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 14 Mar 2026 01:10:03 +0000 Subject: [PATCH 01/16] removing intents --- src/faff_cli/field.py | 4 +- src/faff_cli/log.py | 4 +- src/faff_cli/main.py | 20 +++---- src/faff_cli/plan.py | 18 +----- src/faff_cli/reflect.py | 2 +- src/faff_cli/session.py | 12 ++-- src/faff_cli/sql.py | 18 +++--- src/faff_cli/start.py | 130 +++++++++++++++++----------------------- tests/conftest.py | 41 ++----------- tests/test_cli_log.py | 2 +- tests/test_models.py | 45 +------------- 11 files changed, 90 insertions(+), 206 deletions(-) diff --git a/src/faff_cli/field.py b/src/faff_cli/field.py index b35db70..fd4e878 100644 --- a/src/faff_cli/field.py +++ b/src/faff_cli/field.py @@ -207,10 +207,10 @@ def replace( console = Console() # Update plans via Rust layer - plans_updated, intents_updated = ws.plans.replace_field_in_all_plans( + plans_updated = ws.plans.replace_field_in_all_plans( field, old_value, new_value ) - console.print(f"[green]Updated {intents_updated} intent(s) across {plans_updated} plan(s)[/green]") + console.print(f"[green]Updated {plans_updated} plan(s)[/green]") # Update logs via Rust layer import datetime diff --git a/src/faff_cli/log.py b/src/faff_cli/log.py index afd8c03..578ae55 100644 --- a/src/faff_cli/log.py +++ b/src/faff_cli/log.py @@ -280,8 +280,8 @@ def summary(ctx: typer.Context, date: str = typer.Argument(None)): if stats['mean_reflection_score'] is not None: output += f"Mean reflection score: {stats['mean_reflection_score']:.2f}/5\n" - output += "\nIntent Totals:\n" - for alias, minutes in stats['by_intent'].items(): + output += "\nAlias Totals:\n" + for alias, minutes in stats['by_alias'].items(): output += f"- {alias}: {humanize.precisedelta(datetime.timedelta(minutes=minutes), minimum_unit='minutes')}\n" output += "\nTracker Totals:\n" diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index b95dfa8..5ac0573 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -1,6 +1,6 @@ import typer -from faff_cli import log, id, plan, start, timesheet, intent, field, remote, plugin, reflect, session, sql, __version__ +from faff_cli import log, id, plan, start, timesheet, field, remote, plugin, reflect, session, sql, __version__ from faff_cli.utils import edit_file import faff_core @@ -17,10 +17,9 @@ # Compile and Submit Timesheets cli.add_typer(timesheet.app, name="timesheet", rich_help_panel="Compile and Submit Timesheets") -# Maintain Plans and Intents -cli.add_typer(plan.app, name="plan", rich_help_panel="Maintain Plans and Intents") -cli.add_typer(intent.app, name="intent", rich_help_panel="Maintain Plans and Intents") -cli.add_typer(field.app, name="field", rich_help_panel="Maintain Plans and Intents") +# Maintain Plans and Fields +cli.add_typer(plan.app, name="plan", rich_help_panel="Maintain Plans and Fields") +cli.add_typer(field.app, name="field", rich_help_panel="Maintain Plans and Fields") # Ledger Setup cli.add_typer(remote.app, name="remote", rich_help_panel="Ledger Setup") @@ -394,8 +393,7 @@ def status(ctx: typer.Context): else: freshness = f"[red]{age_days}d ago[/red]" - intent_count = len(plan.intents) - console.print(f" [cyan]{source}[/cyan] · {intent_count} intent(s) · pulled {freshness}") + console.print(f" [cyan]{source}[/cyan] · pulled {freshness}") else: console.print("[yellow] No plans available[/yellow]") @@ -419,9 +417,9 @@ def status(ctx: typer.Context): if active_session: duration_minutes = int(active_session.elapsed(ws.now()).total_seconds() / 60) if active_session.note: - console.print(f" [green]●[/green] {active_session.intent.alias} [dim]({active_session.note})[/dim] · {duration_minutes}m") + console.print(f" [green]●[/green] {active_session.alias} [dim]({active_session.note})[/dim] · {duration_minutes}m") else: - console.print(f" [green]●[/green] {active_session.intent.alias} · {duration_minutes}m") + console.print(f" [green]●[/green] {active_session.alias} · {duration_minutes}m") else: console.print(" [dim]○ Not tracking[/dim]") @@ -563,7 +561,7 @@ def stop(ctx: typer.Context): raise typer.Exit(1) # Capture the details before stopping - intent_alias = active.intent.alias + alias = active.alias start_time = active.start # Stop the session @@ -575,7 +573,7 @@ def stop(ctx: typer.Context): duration_minutes = int(duration.total_seconds() / 60) # Show feedback - typer.echo(f"Stopped '{intent_alias}'") + typer.echo(f"Stopped '{alias}'") typer.echo(f" Started: {start_time.strftime('%H:%M')}") typer.echo(f" Ended: {end_time.strftime('%H:%M')}") typer.echo(f" Duration: {duration_minutes} minutes") diff --git a/src/faff_cli/plan.py b/src/faff_cli/plan.py index 57c05b9..937b92f 100644 --- a/src/faff_cli/plan.py +++ b/src/faff_cli/plan.py @@ -35,13 +35,11 @@ def list_plans( plan_data = [] for plan in plans: valid_until_str = str(plan.valid_until) if plan.valid_until else "∞" - intent_count = len(plan.intents) plan_data.append({ "source": plan.source, "valid_from": str(plan.valid_from), "valid_until": valid_until_str, - "intent_count": intent_count, }) # Create formatter and output @@ -50,7 +48,6 @@ def list_plans( ("source", "Source", "cyan"), ("valid_from", "Valid From", None), ("valid_until", "Valid Until", None), - ("intent_count", "Intents", "green"), ] formatter.print_table( @@ -119,18 +116,6 @@ def show( "source": plan.source, "valid_from": str(plan.valid_from), "valid_until": str(plan.valid_until) if plan.valid_until else None, - "intents": [ - { - "intent_id": intent.intent_id, - "alias": intent.alias, - "role": intent.role, - "objective": intent.objective, - "action": intent.action, - "subject": intent.subject, - "trackers": list(intent.trackers) if intent.trackers else [], - } - for intent in plan.intents - ], } typer.echo(json.dumps(plan_dict, indent=2)) elif plain_output: @@ -143,8 +128,7 @@ def show( console = Console() console.print(f"\n[bold cyan]Plan: {plan.source}[/bold cyan]") - console.print(f"[dim]Valid from {plan.valid_from}{' to ' + str(plan.valid_until) if plan.valid_until else ' onwards'}[/dim]") - console.print(f"[dim]Intents: {len(plan.intents)}[/dim]\n") + console.print(f"[dim]Valid from {plan.valid_from}{' to ' + str(plan.valid_until) if plan.valid_until else ' onwards'}[/dim]\n") # Show as TOML with syntax highlighting toml_content = plan.to_toml() diff --git a/src/faff_cli/reflect.py b/src/faff_cli/reflect.py index aaaece0..9c9d06f 100644 --- a/src/faff_cli/reflect.py +++ b/src/faff_cli/reflect.py @@ -49,7 +49,7 @@ def reflect(ctx: typer.Context, date: str = typer.Argument(None)): end_time = session.end.strftime("%H:%M") if session.end else "ongoing" typer.echo(f"Session {session_idx + 1}/{len(log.timeline)}") typer.echo(f" Time: {start_time} - {end_time}") - typer.echo(f" Intent: {session.intent.alias}") + typer.echo(f" Task: {session.alias}") if session.note: typer.echo(f" Note: {session.note}") typer.echo() diff --git a/src/faff_cli/session.py b/src/faff_cli/session.py index 7401fa8..1b431af 100644 --- a/src/faff_cli/session.py +++ b/src/faff_cli/session.py @@ -114,12 +114,12 @@ def list_sessions( "date_obj": log.date, "start": session.start.strftime("%H:%M:%S"), "end": session.end.strftime("%H:%M:%S") if session.end else "active", - "alias": session.intent.alias, - "role": session.intent.role or "", - "objective": session.intent.objective or "", - "action": session.intent.action or "", - "subject": session.intent.subject or "", - "trackers": ",".join(session.intent.trackers) if session.intent.trackers else "", + "alias": session.alias, + "role": session.role or "", + "objective": session.objective or "", + "action": session.action or "", + "subject": session.subject or "", + "trackers": ",".join(session.trackers) if session.trackers else "", "duration": humanize.precisedelta(duration, minimum_unit="minutes"), "duration_seconds": duration.total_seconds(), "reflection": f"{session.reflection_score:.1f}" if session.reflection_score is not None else "", diff --git a/src/faff_cli/sql.py b/src/faff_cli/sql.py index 248340d..cf847e9 100644 --- a/src/faff_cli/sql.py +++ b/src/faff_cli/sql.py @@ -27,7 +27,6 @@ def load_ledger_to_db(ws, db_path: Path): cursor.execute(''' CREATE TABLE sessions ( date TEXT, - intent_id TEXT, alias TEXT, role TEXT, objective TEXT, @@ -49,8 +48,6 @@ def load_ledger_to_db(ws, db_path: Path): date_str = log.date.isoformat() for session in log.timeline: - intent = session.intent - # Calculate duration in minutes duration = None if session.end: @@ -58,15 +55,14 @@ def load_ledger_to_db(ws, db_path: Path): # Insert session cursor.execute(''' - INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( date_str, - intent.intent_id, - intent.alias, - intent.role, - intent.objective, - intent.action, - intent.subject, + session.alias, + session.role, + session.objective, + session.action, + session.subject, session.start.isoformat(), session.end.isoformat() if session.end else None, duration, @@ -78,7 +74,7 @@ def load_ledger_to_db(ws, db_path: Path): # Create useful indexes cursor.execute('CREATE INDEX idx_sessions_date ON sessions(date)') cursor.execute('CREATE INDEX idx_sessions_role ON sessions(role)') - cursor.execute('CREATE INDEX idx_sessions_intent ON sessions(intent_id)') + cursor.execute('CREATE INDEX idx_sessions_alias ON sessions(alias)') conn.commit() conn.close() diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 7a170ee..f4a9efb 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -6,7 +6,6 @@ from faff_cli.ui import FuzzyItem, fuzzy_select from faff_core import Workspace -from faff_core.models import Intent app = typer.Typer(help="Start a new task or activity.") @@ -33,25 +32,27 @@ def nicer(strings) -> list[FuzzyItem]: ] -def _filter_intents(all_intents, alias, context=None): - """Filter intents by alias, then by context fields, falling back to alias-only if nothing matches.""" - alias_matching = [i for i in all_intents if i.alias == alias] +def _filter_sessions(all_sessions, alias, context=None): + """Filter sessions by alias, then by context fields, falling back to alias-only if nothing matches.""" + alias_matching = [s for s in all_sessions if getattr(s, 'alias', None) == alias] if not context: return alias_matching matching = [ - i for i in alias_matching - if all(getattr(i, k, None) == v for k, v in context.items() if v is not None) + s for s in alias_matching + if all(getattr(s, k, None) == v for k, v in context.items() if v is not None) ] return matching if matching else alias_matching -def get_alias_choices(intents, session_counts_by_id): - """Deduplicate intents by alias and sum session counts per alias.""" - alias_totals = defaultdict(int) - for intent in intents: - alias_totals[intent.alias] += session_counts_by_id.get(intent.intent_id, 0) +def get_alias_choices(all_sessions): + """Build alias choices from historical sessions, sorted by frequency.""" + alias_counts = defaultdict(int) + for session in all_sessions: + alias = getattr(session, 'alias', None) + if alias: + alias_counts[alias] += 1 - sorted_aliases = sorted(alias_totals.items(), key=lambda x: x[1], reverse=True) + sorted_aliases = sorted(alias_counts.items(), key=lambda x: x[1], reverse=True) choices = [] for alias, count in sorted_aliases: @@ -61,15 +62,15 @@ def get_alias_choices(intents, session_counts_by_id): return choices -def get_weighted_field_choices(field_name, alias, all_intents, all_values, session_counts_by_id, context=None): +def get_weighted_field_choices(field_name, alias, all_sessions, all_values, context=None): """Build weighted FuzzyItem list for a field, conditioned on alias and any previously chosen fields.""" - matching = _filter_intents(all_intents, alias, context) + matching = _filter_sessions(all_sessions, alias, context) field_freq = defaultdict(int) - for intent in matching: - val = getattr(intent, field_name, None) + for session in matching: + val = getattr(session, field_name, None) if val: - field_freq[val] += session_counts_by_id.get(intent.intent_id, 0) + field_freq[val] += 1 correlated = sorted(field_freq.items(), key=lambda x: x[1], reverse=True) correlated_set = {v for v, _ in correlated} @@ -92,18 +93,18 @@ def get_weighted_field_choices(field_name, alias, all_intents, all_values, sessi return choices -def get_weighted_tracker_choices(alias, all_intents, all_tracker_ids, tracker_names, session_counts_by_id, context=None, chosen_trackers=None): +def get_weighted_tracker_choices(alias, all_sessions, all_tracker_ids, tracker_names, context=None, chosen_trackers=None): """Build weighted tracker choices conditioned on alias and previously chosen fields. Returns (choices, has_correlated) where has_correlated indicates whether any trackers were historically associated with this context. """ - matching = _filter_intents(all_intents, alias, context) + matching = _filter_sessions(all_sessions, alias, context) tracker_freq = defaultdict(int) - for intent in matching: - for tracker in (intent.trackers or []): - tracker_freq[tracker] += session_counts_by_id.get(intent.intent_id, 0) + for session in matching: + for tracker in (getattr(session, 'trackers', None) or []): + tracker_freq[tracker] += 1 chosen_set = set(chosen_trackers or []) remaining = [t for t in all_tracker_ids if t not in chosen_set] @@ -127,38 +128,8 @@ def get_weighted_tracker_choices(alias, all_intents, all_tracker_ids, tracker_na return choices, bool(correlated) -def match_or_create_intent(ws, alias, role, objective, action, subject, trackers): - """Find an existing intent matching all fields, or create and persist a new one.""" - date = ws.today() - existing_intents = ws.plans.get_intents(date) - - for intent in existing_intents: - if (intent.alias == alias and - intent.role == role and - intent.objective == objective and - intent.action == action and - intent.subject == subject and - sorted(intent.trackers or []) == sorted(trackers or [])): - return intent - - local_plan = ws.plans.get_local_plan_or_create(date) - new_intent = Intent( - alias=alias, - role=role, - objective=objective, - action=action, - subject=subject, - trackers=trackers - ) - new_plan = local_plan.add_intent(new_intent) - ws.plans.write_plan(new_plan) - - # Get the intent back with its generated ID - return [i for i in new_plan.intents if i.alias == alias][-1] - - -def _prompt_for_intent(ws, existing_intents, session_counts): - """Interactively prompt for all intent fields. Returns the matched/created Intent, or None if aborted.""" +def _prompt_for_session_fields(ws, all_sessions): + """Interactively prompt for all session fields. Returns (alias, role, objective, action, subject, trackers), or None if aborted.""" from rich.console import Console console = Console() date = ws.today() @@ -169,7 +140,7 @@ def _prompt_for_intent(ws, existing_intents, session_counts): console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]") chosen_alias, _ = fuzzy_select( prompt="Title:", - choices=get_alias_choices(existing_intents, session_counts), + choices=get_alias_choices(all_sessions), escapable=False, slugify_new=False, ) @@ -182,8 +153,8 @@ def _prompt_for_intent(ws, existing_intents, session_counts): console.print("[bold]I am performing the role of:[/bold]") console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]") role, _ = fuzzy_select("Role:", get_weighted_field_choices( - "role", alias, existing_intents, - list(ws.plans.get_roles(date)), session_counts, + "role", alias, all_sessions, + list(ws.plans.get_roles(date)), ), escapable=True) role_val = role.value if role else None @@ -192,8 +163,8 @@ def _prompt_for_intent(ws, existing_intents, session_counts): console.print("[bold]I hope the impact is:[/bold]") console.print("[dim]e.g. Career Development, New Revenue[/dim]") impact, _ = fuzzy_select("Impact:", get_weighted_field_choices( - "objective", alias, existing_intents, - list(ws.plans.get_objectives(date)), session_counts, + "objective", alias, all_sessions, + list(ws.plans.get_objectives(date)), context={"role": role_val}, ), escapable=True) impact_val = impact.value if impact else None @@ -203,8 +174,8 @@ def _prompt_for_intent(ws, existing_intents, session_counts): console.print("[bold]I am mostly focused on:[/bold]") console.print("[dim]e.g. John Smith, ACME Corporation[/dim]") subject, _ = fuzzy_select("Subject:", get_weighted_field_choices( - "subject", alias, existing_intents, - list(ws.plans.get_subjects(date)), session_counts, + "subject", alias, all_sessions, + list(ws.plans.get_subjects(date)), context={"role": role_val, "objective": impact_val}, ), escapable=True) subject_val = subject.value if subject else None @@ -214,8 +185,8 @@ def _prompt_for_intent(ws, existing_intents, session_counts): console.print("[bold]My primary mode is:[/bold]") console.print("[dim]e.g. Planning, Meeting, Testing[/dim]") mode, _ = fuzzy_select("Mode:", get_weighted_field_choices( - "action", alias, existing_intents, - list(ws.plans.get_actions(date)), session_counts, + "action", alias, all_sessions, + list(ws.plans.get_actions(date)), context={"role": role_val, "objective": impact_val, "subject": subject_val}, ), escapable=True) mode_val = mode.value if mode else None @@ -236,8 +207,8 @@ def _prompt_for_intent(ws, existing_intents, session_counts): break weighted, has_correlated = get_weighted_tracker_choices( - alias, existing_intents, all_tracker_ids, tracker_names, - session_counts, context=full_context, chosen_trackers=trackers, + alias, all_sessions, all_tracker_ids, tracker_names, + context=full_context, chosen_trackers=trackers, ) done_label = "[No trackers]" if not trackers else "[Done]" done_item = FuzzyItem(name=done_label, value=None, decoration=None) @@ -249,7 +220,7 @@ def _prompt_for_intent(ws, existing_intents, session_counts): else: break - return match_or_create_intent(ws, alias, role_val, impact_val, mode_val, subject_val, trackers) + return alias, role_val, impact_val, mode_val, subject_val, trackers @app.callback(invoke_without_command=True) @@ -278,22 +249,31 @@ def start( else: start_time = ws.now() - session_counts = defaultdict(int) + # Gather all historical sessions for weighted choices + all_sessions = [] for log in ws.logs.list_logs(): - for session in log.timeline: - session_counts[session.intent.intent_id] += 1 - - existing_intents = ws.plans.get_intents(date) + all_sessions.extend(log.timeline) - intent = _prompt_for_intent(ws, existing_intents, session_counts) - if not intent: + result = _prompt_for_session_fields(ws, all_sessions) + if not result: typer.echo("aborting") return + alias, role, objective, action, subject, trackers = result + note = input("? Note for this session (optional): ") - ws.logs.start_intent(intent, start_time, note if note else None) - typer.echo(f"Started '{intent.alias}' at {start_time.strftime('%H:%M')}") + ws.logs.start_session( + alias=alias, + role=role, + objective=objective, + action=action, + subject=subject, + trackers=trackers, + start_time=start_time, + note=note if note else None, + ) + typer.echo(f"Started '{alias}' at {start_time.strftime('%H:%M')}") except Exception as e: typer.echo(f"Error starting session: {e}", err=True) raise typer.Exit(1) diff --git a/tests/conftest.py b/tests/conftest.py index c3dbf2b..43b6ca9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import zoneinfo from faff_core import Workspace -from faff_core.models import Intent, Plan +from faff_core.models import Plan @pytest.fixture @@ -54,36 +54,6 @@ def workspace(temp_faff_dir, monkeypatch): return ws -@pytest.fixture -def sample_intent(): - """ - Create a sample Intent for testing. - """ - return Intent( - alias="test-task", - role="developer", - objective="testing", - action="writing", - subject="tests", - trackers=["project:test"] - ) - - -@pytest.fixture -def sample_intent_alt(): - """ - Create an alternative Intent for testing. - """ - return Intent( - alias="other-task", - role="developer", - objective="development", - action="implementing", - subject="feature", - trackers=["project:main"] - ) - - @pytest.fixture def sample_plan_toml(): """ @@ -139,17 +109,16 @@ def workspace_with_log(workspace, temp_faff_dir): Create a workspace with a log entry for today. """ today = workspace.today() - intent = Intent( + + # Start and stop a session + workspace.logs.start_session( alias="existing-task", role="developer", objective="testing", action="coding", subject="tests", - trackers=[] + trackers=[], ) - - # Start and stop a session - workspace.logs.start_intent(intent) workspace.logs.stop_current_session() # Write the log file so it persists diff --git a/tests/test_cli_log.py b/tests/test_cli_log.py index e76e92e..d580058 100644 --- a/tests/test_cli_log.py +++ b/tests/test_cli_log.py @@ -112,7 +112,7 @@ def test_log_summary_with_data(self, workspace_with_log, temp_faff_dir, monkeypa result = runner.invoke(cli, ["log", "summary"]) assert result.exit_code == 0 - assert "Intent Totals" in result.stdout + assert "Alias Totals" in result.stdout assert "Tracker Totals" in result.stdout def test_log_summary_specific_date(self, temp_faff_dir, monkeypatch): diff --git a/tests/test_models.py b/tests/test_models.py index d1248f1..1842921 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,50 +5,7 @@ from pathlib import Path from datetime import date -from faff_core.models import Intent, Plan - - -class TestIntent: - """Test Intent model.""" - - def test_create_intent_with_all_fields(self): - """Should create intent with all fields populated.""" - intent = Intent( - alias="test-task", - role="developer", - objective="testing", - action="writing", - subject="tests", - trackers=["project:test"] - ) - - assert intent.alias == "test-task" - assert intent.role == "developer" - assert intent.objective == "testing" - assert intent.action == "writing" - assert intent.subject == "tests" - assert intent.trackers == ["project:test"] - # intent_id is empty until added to a plan - assert intent.intent_id == "" - - def test_create_intent_minimal(self): - """Should create intent with minimal fields.""" - intent = Intent(alias="simple-task") - - assert intent.alias == "simple-task" - assert intent.role is None - assert intent.objective is None - assert intent.intent_id is not None - - def test_intent_with_multiple_trackers(self): - """Should handle multiple trackers.""" - intent = Intent( - alias="multi-tracker-task", - trackers=["project:a", "project:b", "client:x"] - ) - - assert len(intent.trackers) == 3 - assert "project:a" in intent.trackers +from faff_core.models import Plan class TestPlanFromFile: From a85814a7f5f3df8801159b3b62b300f05ad98767 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 14 Mar 2026 01:35:07 +0000 Subject: [PATCH 02/16] the big renamening (fields got renamed) --- src/faff_cli/field.py | 31 ++++++++--------- src/faff_cli/main.py | 8 ++--- src/faff_cli/reflect.py | 2 +- src/faff_cli/session.py | 16 ++++----- src/faff_cli/sql.py | 14 ++++---- src/faff_cli/start.py | 76 ++++++++++++++++++++--------------------- 6 files changed, 73 insertions(+), 74 deletions(-) diff --git a/src/faff_cli/field.py b/src/faff_cli/field.py index fd4e878..317b4db 100644 --- a/src/faff_cli/field.py +++ b/src/faff_cli/field.py @@ -6,13 +6,13 @@ from faff_cli.output import create_formatter from faff_cli.filtering import parse_simple_filters, apply_filters -app = typer.Typer(help="Manage ASTRO fields (actions, subjects, trackers, roles, objectives)") +app = typer.Typer(help="Manage ASTRO fields (modes, subjects, trackers, roles, impacts)") -VALID_FIELDS = ["role", "objective", "action", "subject", "tracker"] +VALID_FIELDS = ["role", "impact", "mode", "subject", "tracker"] PLURAL_MAP = { "role": "roles", - "objective": "objectives", - "action": "actions", + "impact": "impacts", + "mode": "modes", "subject": "subjects", "tracker": "trackers", } @@ -21,7 +21,7 @@ @app.command() def list( ctx: typer.Context, - field: str = typer.Argument(..., help="Field to list (role, objective, action, subject, tracker)"), + field: str = typer.Argument(..., help="Field to list (role, impact, mode, subject, tracker)"), filter_strings: List[str] = typer.Argument( None, help="Filters: field=value (exact), field~value (contains), field!=value (not equal)", @@ -45,14 +45,14 @@ def list( """ List unique values for a ASTRO field. - Shows field values from both plan-level collections and intents, with usage counts. + Shows field values from both plan-level collections and sessions, with usage counts. Results are sorted by usage (most used first). Examples: faff field list role - faff field list action value~meeting - faff field list role intents>10 - faff field list objective --json + faff field list mode value~meeting + faff field list role sessions>10 + faff field list impact --json """ if field not in VALID_FIELDS: typer.echo(f"Error: field must be one of: {', '.join(VALID_FIELDS)}", err=True) @@ -76,10 +76,10 @@ def list( today = ws.today() if field == "role": all_defined = set(ws.plans.get_roles(today)) - elif field == "objective": - all_defined = set(ws.plans.get_objectives(today)) - elif field == "action": - all_defined = set(ws.plans.get_actions(today)) + elif field == "impact": + all_defined = set(ws.plans.get_impacts(today)) + elif field == "mode": + all_defined = set(ws.plans.get_modes(today)) elif field == "subject": all_defined = set(ws.plans.get_subjects(today)) elif field == "tracker": @@ -182,7 +182,7 @@ def list( @app.command() def replace( ctx: typer.Context, - field: str = typer.Argument(..., help="Field to replace (role, objective, action, subject)"), + field: str = typer.Argument(..., help="Field to replace (role, impact, mode, subject)"), old_value: str = typer.Argument(..., help="Old value to replace"), new_value: str = typer.Argument(..., help="New value"), ): @@ -191,8 +191,7 @@ def replace( This will: - Update the field in plan-level ASTRO collections - - Update all intents that use the old value - - Update all log sessions that reference those intents + - Update all log sessions that use the old value """ if field not in VALID_FIELDS: typer.echo(f"Error: field must be one of: {', '.join(VALID_FIELDS)}", err=True) diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index 5ac0573..1f20171 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -417,9 +417,9 @@ def status(ctx: typer.Context): if active_session: duration_minutes = int(active_session.elapsed(ws.now()).total_seconds() / 60) if active_session.note: - console.print(f" [green]●[/green] {active_session.alias} [dim]({active_session.note})[/dim] · {duration_minutes}m") + console.print(f" [green]●[/green] {active_session.title} [dim]({active_session.note})[/dim] · {duration_minutes}m") else: - console.print(f" [green]●[/green] {active_session.alias} · {duration_minutes}m") + console.print(f" [green]●[/green] {active_session.title} · {duration_minutes}m") else: console.print(" [dim]○ Not tracking[/dim]") @@ -561,7 +561,7 @@ def stop(ctx: typer.Context): raise typer.Exit(1) # Capture the details before stopping - alias = active.alias + title = active.title start_time = active.start # Stop the session @@ -573,7 +573,7 @@ def stop(ctx: typer.Context): duration_minutes = int(duration.total_seconds() / 60) # Show feedback - typer.echo(f"Stopped '{alias}'") + typer.echo(f"Stopped '{title}'") typer.echo(f" Started: {start_time.strftime('%H:%M')}") typer.echo(f" Ended: {end_time.strftime('%H:%M')}") typer.echo(f" Duration: {duration_minutes} minutes") diff --git a/src/faff_cli/reflect.py b/src/faff_cli/reflect.py index 9c9d06f..2fc4dda 100644 --- a/src/faff_cli/reflect.py +++ b/src/faff_cli/reflect.py @@ -49,7 +49,7 @@ def reflect(ctx: typer.Context, date: str = typer.Argument(None)): end_time = session.end.strftime("%H:%M") if session.end else "ongoing" typer.echo(f"Session {session_idx + 1}/{len(log.timeline)}") typer.echo(f" Time: {start_time} - {end_time}") - typer.echo(f" Task: {session.alias}") + typer.echo(f" Task: {session.title}") if session.note: typer.echo(f" Note: {session.note}") typer.echo() diff --git a/src/faff_cli/session.py b/src/faff_cli/session.py index 1b431af..b28c31a 100644 --- a/src/faff_cli/session.py +++ b/src/faff_cli/session.py @@ -73,7 +73,7 @@ def list_sessions( Examples: faff session list faff session list --from 2025-01-01 --to 2025-01-31 - faff session list alias~meeting + faff session list title~meeting faff session list role=consultant --since last-monday faff session list --json """ @@ -114,10 +114,10 @@ def list_sessions( "date_obj": log.date, "start": session.start.strftime("%H:%M:%S"), "end": session.end.strftime("%H:%M:%S") if session.end else "active", - "alias": session.alias, + "title": session.title, "role": session.role or "", - "objective": session.objective or "", - "action": session.action or "", + "impact": session.impact or "", + "mode": session.mode or "", "subject": session.subject or "", "trackers": ",".join(session.trackers) if session.trackers else "", "duration": humanize.precisedelta(duration, minimum_unit="minutes"), @@ -145,7 +145,7 @@ def list_sessions( ("date", "Date", "cyan"), ("start", "Start", None), ("end", "End", None), - ("alias", "Intent", "yellow"), + ("title", "Title", "yellow"), ("duration", "Duration", "green"), ("reflection", "Reflection", "blue"), ] @@ -179,7 +179,7 @@ def report( group: Optional[str] = typer.Option( None, "--group", "-g", - help="Field to group by (e.g. date, role, objective, subject, alias).", + help="Field to group by (e.g. date, role, impact, subject, title).", ), from_date: Optional[str] = typer.Option( None, @@ -220,9 +220,9 @@ def report( Examples: faff session report role=consultant - faff session report alias~meeting --from 2025-01-01 + faff session report title~meeting --from 2025-01-01 faff session report --since last-monday - faff session report objective=alignment --sum + faff session report impact=alignment --sum """ try: ws = ctx.obj diff --git a/src/faff_cli/sql.py b/src/faff_cli/sql.py index cf847e9..a4a6442 100644 --- a/src/faff_cli/sql.py +++ b/src/faff_cli/sql.py @@ -27,10 +27,10 @@ def load_ledger_to_db(ws, db_path: Path): cursor.execute(''' CREATE TABLE sessions ( date TEXT, - alias TEXT, + title TEXT, role TEXT, - objective TEXT, - action TEXT, + impact TEXT, + mode TEXT, subject TEXT, start TEXT, end TEXT, @@ -58,10 +58,10 @@ def load_ledger_to_db(ws, db_path: Path): INSERT INTO sessions VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( date_str, - session.alias, + session.title, session.role, - session.objective, - session.action, + session.impact, + session.mode, session.subject, session.start.isoformat(), session.end.isoformat() if session.end else None, @@ -74,7 +74,7 @@ def load_ledger_to_db(ws, db_path: Path): # Create useful indexes cursor.execute('CREATE INDEX idx_sessions_date ON sessions(date)') cursor.execute('CREATE INDEX idx_sessions_role ON sessions(role)') - cursor.execute('CREATE INDEX idx_sessions_alias ON sessions(alias)') + cursor.execute('CREATE INDEX idx_sessions_title ON sessions(title)') conn.commit() conn.close() diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index f4a9efb..66073b3 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -32,32 +32,32 @@ def nicer(strings) -> list[FuzzyItem]: ] -def _filter_sessions(all_sessions, alias, context=None): - """Filter sessions by alias, then by context fields, falling back to alias-only if nothing matches.""" - alias_matching = [s for s in all_sessions if getattr(s, 'alias', None) == alias] +def _filter_sessions(all_sessions, title, context=None): + """Filter sessions by title, then by context fields, falling back to title-only if nothing matches.""" + title_matching = [s for s in all_sessions if getattr(s, 'title', None) == title] if not context: - return alias_matching + return title_matching matching = [ - s for s in alias_matching + s for s in title_matching if all(getattr(s, k, None) == v for k, v in context.items() if v is not None) ] - return matching if matching else alias_matching + return matching if matching else title_matching -def get_alias_choices(all_sessions): - """Build alias choices from historical sessions, sorted by frequency.""" - alias_counts = defaultdict(int) +def get_title_choices(all_sessions): + """Build title choices from historical sessions, sorted by frequency.""" + title_counts = defaultdict(int) for session in all_sessions: - alias = getattr(session, 'alias', None) - if alias: - alias_counts[alias] += 1 + title = getattr(session, 'title', None) + if title: + title_counts[title] += 1 - sorted_aliases = sorted(alias_counts.items(), key=lambda x: x[1], reverse=True) + sorted_titles = sorted(title_counts.items(), key=lambda x: x[1], reverse=True) choices = [] - for alias, count in sorted_aliases: + for title, count in sorted_titles: decoration = f"({count})" if count > 0 else None - choices.append(FuzzyItem(name=alias, value=alias, decoration=decoration)) + choices.append(FuzzyItem(name=title, value=title, decoration=decoration)) return choices @@ -134,37 +134,37 @@ def _prompt_for_session_fields(ws, all_sessions): console = Console() date = ws.today() - # Step 1: Alias + # Step 1: Title console.print() console.print("[bold]I am doing/making/attending:[/bold]") console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]") - chosen_alias, _ = fuzzy_select( + chosen_title, _ = fuzzy_select( prompt="Title:", - choices=get_alias_choices(all_sessions), + choices=get_title_choices(all_sessions), escapable=False, slugify_new=False, ) - if not chosen_alias: + if not chosen_title: return None - alias = chosen_alias.value + title = chosen_title.value # Step 2: Role console.print() console.print("[bold]I am performing the role of:[/bold]") console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]") role, _ = fuzzy_select("Role:", get_weighted_field_choices( - "role", alias, all_sessions, + "role", title, all_sessions, list(ws.plans.get_roles(date)), ), escapable=True) role_val = role.value if role else None - # Step 3: Impact (objective in data model) + # Step 3: Impact console.print() console.print("[bold]I hope the impact is:[/bold]") console.print("[dim]e.g. Career Development, New Revenue[/dim]") impact, _ = fuzzy_select("Impact:", get_weighted_field_choices( - "objective", alias, all_sessions, - list(ws.plans.get_objectives(date)), + "impact", title, all_sessions, + list(ws.plans.get_impacts(date)), context={"role": role_val}, ), escapable=True) impact_val = impact.value if impact else None @@ -174,20 +174,20 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[bold]I am mostly focused on:[/bold]") console.print("[dim]e.g. John Smith, ACME Corporation[/dim]") subject, _ = fuzzy_select("Subject:", get_weighted_field_choices( - "subject", alias, all_sessions, + "subject", title, all_sessions, list(ws.plans.get_subjects(date)), - context={"role": role_val, "objective": impact_val}, + context={"role": role_val, "impact": impact_val}, ), escapable=True) subject_val = subject.value if subject else None - # Step 5: Mode (action in data model) + # Step 5: Mode console.print() console.print("[bold]My primary mode is:[/bold]") console.print("[dim]e.g. Planning, Meeting, Testing[/dim]") mode, _ = fuzzy_select("Mode:", get_weighted_field_choices( - "action", alias, all_sessions, - list(ws.plans.get_actions(date)), - context={"role": role_val, "objective": impact_val, "subject": subject_val}, + "mode", title, all_sessions, + list(ws.plans.get_modes(date)), + context={"role": role_val, "impact": impact_val, "subject": subject_val}, ), escapable=True) mode_val = mode.value if mode else None @@ -201,13 +201,13 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[bold]Time spent in this session should be logged to:[/bold]") console.print("[dim]e.g. PROJ-123, Billable: ACME[/dim]") - full_context = {"role": role_val, "objective": impact_val, "subject": subject_val, "action": mode_val} + full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val} while True: if all(t in trackers for t in all_tracker_ids): break weighted, has_correlated = get_weighted_tracker_choices( - alias, all_sessions, all_tracker_ids, tracker_names, + title, all_sessions, all_tracker_ids, tracker_names, context=full_context, chosen_trackers=trackers, ) done_label = "[No trackers]" if not trackers else "[Done]" @@ -220,7 +220,7 @@ def _prompt_for_session_fields(ws, all_sessions): else: break - return alias, role_val, impact_val, mode_val, subject_val, trackers + return title, role_val, impact_val, mode_val, subject_val, trackers @app.callback(invoke_without_command=True) @@ -259,21 +259,21 @@ def start( typer.echo("aborting") return - alias, role, objective, action, subject, trackers = result + title, role, impact, mode, subject, trackers = result note = input("? Note for this session (optional): ") ws.logs.start_session( - alias=alias, + title=title, role=role, - objective=objective, - action=action, + impact=impact, + mode=mode, subject=subject, trackers=trackers, start_time=start_time, note=note if note else None, ) - typer.echo(f"Started '{alias}' at {start_time.strftime('%H:%M')}") + typer.echo(f"Started '{title}' at {start_time.strftime('%H:%M')}") except Exception as e: typer.echo(f"Error starting session: {e}", err=True) raise typer.Exit(1) From 568bc042dda53e3a165112fe79f865f4e22a003e Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 14 Mar 2026 10:59:59 +0000 Subject: [PATCH 03/16] fix bug - missing historical nouns --- src/faff_cli/start.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 66073b3..4ce504c 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -74,7 +74,11 @@ def get_weighted_field_choices(field_name, alias, all_sessions, all_values, cont correlated = sorted(field_freq.items(), key=lambda x: x[1], reverse=True) correlated_set = {v for v, _ in correlated} - others = [v for v in all_values if v not in correlated_set] + + all_historical = {getattr(s, field_name, None) for s in all_sessions} + all_historical.discard(None) + all_known = dict.fromkeys(list(all_values) + sorted(all_historical)) # plan-defined first, preserve order, dedupe + others = [v for v in all_known if v not in correlated_set] choices = [] for val, count in correlated: From 0cfa91003399ebf1fff1dfbaeef1ae4caa0a2c4b Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 14 Mar 2026 21:36:25 +0000 Subject: [PATCH 04/16] Making the cli configurable independently of the faff ledger --- src/faff_cli/config.py | 69 ++++++++++++ src/faff_cli/start.py | 186 ++++++++++++++++---------------- src/faff_cli/ui/fuzzy_select.py | 8 +- 3 files changed, 169 insertions(+), 94 deletions(-) create mode 100644 src/faff_cli/config.py diff --git a/src/faff_cli/config.py b/src/faff_cli/config.py new file mode 100644 index 0000000..7d55004 --- /dev/null +++ b/src/faff_cli/config.py @@ -0,0 +1,69 @@ +""" +faff-cli configuration. + +Read from the [faff-cli] section of the ledger's config.toml. +All settings default to True if the section is absent. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import List + +import toml + + +ALL_FIELDS = ["title", "role", "subject", "impact", "mode", "trackers", "note"] + + +@dataclass +class SessionConfig: + title: bool = True + role: bool = True + impact: bool = True + mode: bool = True + subject: bool = True + trackers: bool = True + note: bool = True + + def enabled_fields(self) -> List[str]: + """Return field names in canonical order, filtered to enabled ones.""" + return [f for f in ALL_FIELDS if getattr(self, f)] + + +@dataclass +class CliConfig: + session: SessionConfig = field(default_factory=SessionConfig) + + +def _ledger_config_path() -> Path: + faff_dir = os.environ.get("FAFF_DIR") + root = Path(faff_dir) if faff_dir else Path.home() / ".faff" + return root / "config.toml" + + +def load_config() -> CliConfig: + path = _ledger_config_path() + if not path.exists(): + return CliConfig() + + try: + data = toml.load(path) + except Exception: + return CliConfig() + + cli_data = data.get("faff-cli", {}) + session_data = cli_data.get("session", {}) + session = SessionConfig( + title=session_data.get("title", True), + role=session_data.get("role", True), + impact=session_data.get("impact", True), + mode=session_data.get("mode", True), + subject=session_data.get("subject", True), + trackers=session_data.get("trackers", True), + note=session_data.get("note", True), + ) + + return CliConfig(session=session) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 4ce504c..ba0050c 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -4,6 +4,7 @@ from titlecase import titlecase from faff_cli.ui import FuzzyItem, fuzzy_select +from faff_cli.config import load_config from faff_core import Workspace @@ -133,96 +134,103 @@ def get_weighted_tracker_choices(alias, all_sessions, all_tracker_ids, tracker_n def _prompt_for_session_fields(ws, all_sessions): - """Interactively prompt for all session fields. Returns (alias, role, objective, action, subject, trackers), or None if aborted.""" + """Interactively prompt for session fields per CLI config. Returns field dict, or None if aborted.""" from rich.console import Console console = Console() date = ws.today() - - # Step 1: Title - console.print() - console.print("[bold]I am doing/making/attending:[/bold]") - console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]") - chosen_title, _ = fuzzy_select( - prompt="Title:", - choices=get_title_choices(all_sessions), - escapable=False, - slugify_new=False, - ) - if not chosen_title: - return None - title = chosen_title.value - - # Step 2: Role - console.print() - console.print("[bold]I am performing the role of:[/bold]") - console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]") - role, _ = fuzzy_select("Role:", get_weighted_field_choices( - "role", title, all_sessions, - list(ws.plans.get_roles(date)), - ), escapable=True) - role_val = role.value if role else None - - # Step 3: Impact - console.print() - console.print("[bold]I hope the impact is:[/bold]") - console.print("[dim]e.g. Career Development, New Revenue[/dim]") - impact, _ = fuzzy_select("Impact:", get_weighted_field_choices( - "impact", title, all_sessions, - list(ws.plans.get_impacts(date)), - context={"role": role_val}, - ), escapable=True) - impact_val = impact.value if impact else None - - # Step 4: Subject - console.print() - console.print("[bold]I am mostly focused on:[/bold]") - console.print("[dim]e.g. John Smith, ACME Corporation[/dim]") - subject, _ = fuzzy_select("Subject:", get_weighted_field_choices( - "subject", title, all_sessions, - list(ws.plans.get_subjects(date)), - context={"role": role_val, "impact": impact_val}, - ), escapable=True) - subject_val = subject.value if subject else None - - # Step 5: Mode - console.print() - console.print("[bold]My primary mode is:[/bold]") - console.print("[dim]e.g. Planning, Meeting, Testing[/dim]") - mode, _ = fuzzy_select("Mode:", get_weighted_field_choices( - "mode", title, all_sessions, - list(ws.plans.get_modes(date)), - context={"role": role_val, "impact": impact_val, "subject": subject_val}, - ), escapable=True) - mode_val = mode.value if mode else None - - # Step 6: Trackers + cfg = load_config().session + enabled = cfg.enabled_fields() + + title = None + role_val = None + impact_val = None + mode_val = None + subject_val = None trackers: list[str] = [] - tracker_names = ws.plans.get_trackers(date) - all_tracker_ids = list(tracker_names.keys()) - if all_tracker_ids: + if "title" in enabled: console.print() - console.print("[bold]Time spent in this session should be logged to:[/bold]") - console.print("[dim]e.g. PROJ-123, Billable: ACME[/dim]") - - full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val} - while True: - if all(t in trackers for t in all_tracker_ids): - break - - weighted, has_correlated = get_weighted_tracker_choices( - title, all_sessions, all_tracker_ids, tracker_names, - context=full_context, chosen_trackers=trackers, - ) - done_label = "[No trackers]" if not trackers else "[Done]" - done_item = FuzzyItem(name=done_label, value=None, decoration=None) - choices = weighted + [done_item] if has_correlated else [done_item] + weighted - - picked, _ = fuzzy_select(prompt="Tracker:", choices=choices, escapable=True, create_new=False) - if picked and picked.value: - trackers.append(picked.value) - else: - break + console.print("[bold]I am doing/making/attending:[/bold]") + console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]") + chosen_title, _ = fuzzy_select( + prompt=None, + choices=get_title_choices(all_sessions), + escapable=True, + slugify_new=False, + ) + if chosen_title: + title = chosen_title.value + + if "role" in enabled: + console.print() + console.print("[bold]I am performing the role of:[/bold]") + console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]") + role, _ = fuzzy_select(None, get_weighted_field_choices( + "role", title, all_sessions, + list(ws.plans.get_roles(date)), + ), escapable=True) + role_val = role.value if role else None + + if "subject" in enabled: + console.print() + console.print("[bold]I am mostly focused on:[/bold]") + console.print("[dim]e.g. John Smith, ACME Corporation, Sales Team[/dim]") + subject, _ = fuzzy_select(None, get_weighted_field_choices( + "subject", title, all_sessions, + list(ws.plans.get_subjects(date)), + context={"role": role_val}, + ), escapable=True) + subject_val = subject.value if subject else None + + if "impact" in enabled: + console.print() + console.print("[bold]I hope the impact is:[/bold]") + console.print("[dim]e.g. Career Development, New Revenue[/dim]") + impact, _ = fuzzy_select(None, get_weighted_field_choices( + "impact", title, all_sessions, + list(ws.plans.get_impacts(date)), + context={"role": role_val, "subject": subject_val}, + ), escapable=True) + impact_val = impact.value if impact else None + + if "mode" in enabled: + console.print() + console.print("[bold]My primary mode is:[/bold]") + console.print("[dim]e.g. Planning, Meeting, Testing[/dim]") + mode, _ = fuzzy_select(None, get_weighted_field_choices( + "mode", title, all_sessions, + list(ws.plans.get_modes(date)), + context={"role": role_val, "subject": subject_val, "impact": impact_val}, + ), escapable=True) + mode_val = mode.value if mode else None + + if "trackers" in enabled: + tracker_names = ws.plans.get_trackers(date) + all_tracker_ids = list(tracker_names.keys()) + + if all_tracker_ids: + console.print() + console.print("[bold]Time spent in this session should be logged to:[/bold]") + console.print("[dim]e.g. PROJ-123, Billable: ACME[/dim]") + + full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val} + while True: + if all(t in trackers for t in all_tracker_ids): + break + + weighted, has_correlated = get_weighted_tracker_choices( + title, all_sessions, all_tracker_ids, tracker_names, + context=full_context, chosen_trackers=trackers, + ) + done_label = "[No trackers]" if not trackers else "[Done]" + done_item = FuzzyItem(name=done_label, value=None, decoration=None) + choices = weighted + [done_item] if has_correlated else [done_item] + weighted + + picked, _ = fuzzy_select(prompt=None, choices=choices, escapable=True, create_new=False) + if picked and picked.value: + trackers.append(picked.value) + else: + break return title, role_val, impact_val, mode_val, subject_val, trackers @@ -258,14 +266,10 @@ def start( for log in ws.logs.list_logs(): all_sessions.extend(log.timeline) - result = _prompt_for_session_fields(ws, all_sessions) - if not result: - typer.echo("aborting") - return - - title, role, impact, mode, subject, trackers = result + title, role, impact, mode, subject, trackers = _prompt_for_session_fields(ws, all_sessions) - note = input("? Note for this session (optional): ") + cfg = load_config().session + note = input("? Note for this session (optional): ") if cfg.note else None ws.logs.start_session( title=title, diff --git a/src/faff_cli/ui/fuzzy_select.py b/src/faff_cli/ui/fuzzy_select.py index 554edf3..027f24d 100644 --- a/src/faff_cli/ui/fuzzy_select.py +++ b/src/faff_cli/ui/fuzzy_select.py @@ -55,7 +55,7 @@ def slugify_preserving_slashes(path: str, **kwargs) -> str: segments = path.split("/") return "/".join(slugify(seg, **kwargs) for seg in segments) -def fuzzy_select(prompt: str, +def fuzzy_select(prompt: str | None, choices: Sequence[Union[str, FuzzyItem]], create_new: bool = True, max_fraction: float = 0.5, @@ -188,7 +188,9 @@ def get_menu_tokens(): return tokens - prompt_win = Window(height=1, content=FormattedTextControl('? ' + prompt)) + layout_children = [] + if prompt: + layout_children.append(Window(height=1, content=FormattedTextControl('? ' + prompt))) def get_input_width(): return len(buf.text) + 1 # +1 for caret space @@ -212,7 +214,7 @@ def get_input_width(): wrap_lines=False, height=Dimension(max=max_rows)) - root = HSplit([prompt_win, input_row, menu_win]) + root = HSplit(layout_children + [input_row, menu_win]) app: Application = Application(layout=Layout(root), key_bindings=kb, style=style, From f39db7a892343b6159057594677758c6098a43a4 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sun, 15 Mar 2026 13:03:13 +0000 Subject: [PATCH 05/16] Do a better job of populating the suggested options from the historical log and plan/plan hints. --- src/faff_cli/start.py | 186 +++++++++++++++++++++++++++----- src/faff_cli/ui/fuzzy_select.py | 35 +++--- 2 files changed, 177 insertions(+), 44 deletions(-) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index ba0050c..31cf6ba 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -1,5 +1,6 @@ import typer from collections import defaultdict +from types import SimpleNamespace from titlecase import titlecase @@ -46,49 +47,82 @@ def _filter_sessions(all_sessions, title, context=None): def get_title_choices(all_sessions): - """Build title choices from historical sessions, sorted by frequency.""" + """Build title choices from historical sessions, sorted by frequency. + + Hint sessions (synthetic, from plans) are shown with a '→' marker. + Historical sessions are shown with a count like (3). + """ title_counts = defaultdict(int) + title_is_hint = {} for session in all_sessions: title = getattr(session, 'title', None) - if title: + if not title: + continue + if getattr(session, '_is_hint', False): + title_is_hint.setdefault(title, True) + else: title_counts[title] += 1 + title_is_hint[title] = False - sorted_titles = sorted(title_counts.items(), key=lambda x: x[1], reverse=True) + # Separate historical (count > 0) from hints-only + historical = [(t, c) for t, c in title_counts.items() if c > 0] + historical.sort(key=lambda x: x[1], reverse=True) + hints_only = [t for t, is_hint in title_is_hint.items() if is_hint and t not in title_counts] choices = [] - for title, count in sorted_titles: - decoration = f"({count})" if count > 0 else None - choices.append(FuzzyItem(name=title, value=title, decoration=decoration)) + for title, count in historical: + choices.append(FuzzyItem(name=title, value=title, decoration=f"({count})")) + for title in hints_only: + choices.append(FuzzyItem(name=title, value=title, decoration="→")) return choices def get_weighted_field_choices(field_name, alias, all_sessions, all_values, context=None): - """Build weighted FuzzyItem list for a field, conditioned on alias and any previously chosen fields.""" + """Build weighted FuzzyItem list for a field, conditioned on alias and any previously chosen fields. + + Real historical sessions drive frequency counts and decoration. + Hint sessions (synthetic) contribute ordering only — they appear with '→' if not in history. + """ matching = _filter_sessions(all_sessions, alias, context) + # Count only real sessions for frequency display field_freq = defaultdict(int) + hint_only_vals = set() for session in matching: val = getattr(session, field_name, None) - if val: + if not val: + continue + if getattr(session, '_is_hint', False): + hint_only_vals.add(val) + else: field_freq[val] += 1 + # Values in hint_only_vals that also appear in real history are not hint-only + hint_only_vals -= set(field_freq.keys()) + correlated = sorted(field_freq.items(), key=lambda x: x[1], reverse=True) correlated_set = {v for v, _ in correlated} - all_historical = {getattr(s, field_name, None) for s in all_sessions} + all_historical = {getattr(s, field_name, None) for s in all_sessions if not getattr(s, '_is_hint', False)} all_historical.discard(None) - all_known = dict.fromkeys(list(all_values) + sorted(all_historical)) # plan-defined first, preserve order, dedupe - others = [v for v in all_known if v not in correlated_set] + all_known = dict.fromkeys(list(all_values) + sorted(all_historical)) + others = [v for v in all_known if v not in correlated_set and v not in hint_only_vals] choices = [] for val, count in correlated: name = prettify_path_label(val) if not name: continue - decoration = f"{val} ({count})" if count > 0 else val + decoration = f"{val} ({count})" choices.append(FuzzyItem(name=name, value=val, decoration=decoration)) + for val in hint_only_vals: + name = prettify_path_label(val) + if not name: + continue + choices.append(FuzzyItem(name=name, value=val, decoration=f"{val} →")) + for val in others: name = prettify_path_label(val) if not name: @@ -133,6 +167,41 @@ def get_weighted_tracker_choices(alias, all_sessions, all_tracker_ids, tracker_n return choices, bool(correlated) +def _hints_to_sessions(hints): + """Convert hint dicts to session-like objects for the heuristic weighting pool.""" + return [ + SimpleNamespace( + title=h["title"], + role=h.get("role"), + subject=h.get("subject"), + impact=h.get("impact"), + mode=h.get("mode"), + trackers=h.get("trackers") or [], + _is_hint=True, + ) + for h in hints + ] + + +def _resolve_tracker(hints, role, subject, impact, mode, exclude=None): + """Find hints whose field constraints all match the given session values.""" + exclude = set(exclude or []) + matches = [] + for h in hints: + tracker_ids = h.get("trackers") or [] + if not tracker_ids: + continue + if all(tid in exclude for tid in tracker_ids): + continue + if (h.get("role") is None or h["role"] == role) and \ + (h.get("subject") is None or h["subject"] == subject) and \ + (h.get("impact") is None or h["impact"] == impact) and \ + (h.get("mode") is None or h["mode"] == mode): + if any(h.get(f) is not None for f in ("role", "subject", "impact", "mode")): + matches.append(h) + return matches + + def _prompt_for_session_fields(ws, all_sessions): """Interactively prompt for session fields per CLI config. Returns field dict, or None if aborted.""" from rich.console import Console @@ -141,6 +210,19 @@ def _prompt_for_session_fields(ws, all_sessions): cfg = load_config().session enabled = cfg.enabled_fields() + # Load all plan data in a single call (one disk read instead of 7+) + plan_data = ws.plans.get_start_data(date) + plan_hints = plan_data["hints"] + plan_hint_tracker_ids = {tid for h in plan_hints for tid in (h.get("trackers") or [])} + tracker_mapping_hints = [ + {"title": m.get("hint_title") or m["tracker_name"], "role": m["role"], "subject": m["subject"], + "impact": m["impact"], "mode": m["mode"], "trackers": [m["tracker_id"]]} + for m in plan_data["tracker_mappings"] + if m["tracker_id"] not in plan_hint_tracker_ids + ] + all_hints = plan_hints + tracker_mapping_hints + sessions_with_hints = list(all_sessions) + _hints_to_sessions(all_hints) + title = None role_val = None impact_val = None @@ -154,7 +236,7 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[dim]e.g. 1:1 with Yan, Monthly Strategy Meeting[/dim]") chosen_title, _ = fuzzy_select( prompt=None, - choices=get_title_choices(all_sessions), + choices=get_title_choices(sessions_with_hints), escapable=True, slugify_new=False, ) @@ -166,8 +248,8 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[bold]I am performing the role of:[/bold]") console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]") role, _ = fuzzy_select(None, get_weighted_field_choices( - "role", title, all_sessions, - list(ws.plans.get_roles(date)), + "role", title, sessions_with_hints, + plan_data["roles"], ), escapable=True) role_val = role.value if role else None @@ -176,8 +258,8 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[bold]I am mostly focused on:[/bold]") console.print("[dim]e.g. John Smith, ACME Corporation, Sales Team[/dim]") subject, _ = fuzzy_select(None, get_weighted_field_choices( - "subject", title, all_sessions, - list(ws.plans.get_subjects(date)), + "subject", title, sessions_with_hints, + plan_data["subjects"], context={"role": role_val}, ), escapable=True) subject_val = subject.value if subject else None @@ -187,8 +269,8 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[bold]I hope the impact is:[/bold]") console.print("[dim]e.g. Career Development, New Revenue[/dim]") impact, _ = fuzzy_select(None, get_weighted_field_choices( - "impact", title, all_sessions, - list(ws.plans.get_impacts(date)), + "impact", title, sessions_with_hints, + plan_data["impacts"], context={"role": role_val, "subject": subject_val}, ), escapable=True) impact_val = impact.value if impact else None @@ -198,28 +280,80 @@ def _prompt_for_session_fields(ws, all_sessions): console.print("[bold]My primary mode is:[/bold]") console.print("[dim]e.g. Planning, Meeting, Testing[/dim]") mode, _ = fuzzy_select(None, get_weighted_field_choices( - "mode", title, all_sessions, - list(ws.plans.get_modes(date)), + "mode", title, sessions_with_hints, + plan_data["modes"], context={"role": role_val, "subject": subject_val, "impact": impact_val}, ), escapable=True) mode_val = mode.value if mode else None if "trackers" in enabled: - tracker_names = ws.plans.get_trackers(date) + tracker_names = plan_data["trackers"] all_tracker_ids = list(tracker_names.keys()) if all_tracker_ids: + full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val} + + auto_matches = _resolve_tracker( + all_hints, role_val, subject_val, impact_val, mode_val, + exclude=trackers, + ) + # Flatten to a deduplicated list of suggested tracker IDs + auto_tracker_ids = list(dict.fromkeys( + tid for m in auto_matches for tid in (m.get("trackers") or []) + if tid not in trackers + )) + console.print() console.print("[bold]Time spent in this session should be logged to:[/bold]") console.print("[dim]e.g. PROJ-123, Billable: ACME[/dim]") - full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val} + if len(auto_tracker_ids) == 1: + # Single match: show as top suggestion, user confirms or overrides + tid = auto_tracker_ids[0] + name = tracker_names.get(tid, tid) + suggested = FuzzyItem(name=name, value=tid, decoration=f"{tid} (suggested)") + done_item = FuzzyItem(name="[No trackers]", value=None, decoration=None) + other_ids = [t for t in all_tracker_ids if t != tid] + other_choices, _ = get_weighted_tracker_choices( + title, sessions_with_hints, other_ids, tracker_names, + context=full_context, chosen_trackers=trackers, + ) + picked, _ = fuzzy_select( + prompt=None, + choices=[suggested, done_item] + other_choices, + escapable=True, + create_new=False, + ) + if picked and picked.value: + trackers.append(picked.value) + + elif len(auto_tracker_ids) > 1: + # Multiple matches: prompt from the matching subset first + auto_subset = [tid for tid in all_tracker_ids if tid in auto_tracker_ids] + while True: + remaining_subset = [t for t in auto_subset if t not in trackers] + if not remaining_subset: + break + weighted, has_correlated = get_weighted_tracker_choices( + title, sessions_with_hints, remaining_subset, tracker_names, + context=full_context, chosen_trackers=trackers, + ) + done_label = "[No trackers]" if not trackers else "[Done]" + done_item = FuzzyItem(name=done_label, value=None, decoration=None) + choices = weighted + [done_item] if has_correlated else [done_item] + weighted + picked, _ = fuzzy_select(prompt=None, choices=choices, escapable=True, create_new=False) + if picked and picked.value: + trackers.append(picked.value) + else: + break + + # Allow adding more (or full manual selection if no auto-match) while True: if all(t in trackers for t in all_tracker_ids): break weighted, has_correlated = get_weighted_tracker_choices( - title, all_sessions, all_tracker_ids, tracker_names, + title, sessions_with_hints, all_tracker_ids, tracker_names, context=full_context, chosen_trackers=trackers, ) done_label = "[No trackers]" if not trackers else "[Done]" @@ -261,9 +395,9 @@ def start( else: start_time = ws.now() - # Gather all historical sessions for weighted choices + # Gather recent historical sessions for weighted choices (last 90 days is plenty) all_sessions = [] - for log in ws.logs.list_logs(): + for log in ws.logs.list_logs_recent(90): all_sessions.extend(log.timeline) title, role, impact, mode, subject, trackers = _prompt_for_session_fields(ws, all_sessions) diff --git a/src/faff_cli/ui/fuzzy_select.py b/src/faff_cli/ui/fuzzy_select.py index 027f24d..b366c48 100644 --- a/src/faff_cli/ui/fuzzy_select.py +++ b/src/faff_cli/ui/fuzzy_select.py @@ -1,29 +1,13 @@ -from slugify import slugify - -import html - -from prompt_toolkit import Application -from prompt_toolkit.buffer import Buffer -from prompt_toolkit.layout import Layout, HSplit, Window, VSplit -from prompt_toolkit.layout.dimension import Dimension -from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.styles import Style -from prompt_toolkit.shortcuts import print_formatted_text -from prompt_toolkit.formatted_text import HTML - from typing import Union, Any, Optional, List, Sequence from dataclasses import dataclass -from pfzy import fzy_scorer - -style = Style.from_dict({ +_STYLE_DICT = { "selector": "fg:ansigray", "match": "fg:ansimagenta bold", "select": "fg:ansiblue", "decoration": "fg:ansigreen", "count": "fg:ansigreen", -}) +} @dataclass class FuzzyItem: @@ -52,6 +36,7 @@ def normalize_to_fuzzyitems(items: Sequence[Any]) -> List[FuzzyItem]: raise TypeError("Expected list of str, dicts with name/value, or FuzzyItem instances") def slugify_preserving_slashes(path: str, **kwargs) -> str: + from slugify import slugify segments = path.split("/") return "/".join(slugify(seg, **kwargs) for seg in segments) @@ -63,7 +48,21 @@ def fuzzy_select(prompt: str | None, slugify_new: bool = True, multi_select_key: bool = False) -> tuple[FuzzyItem | None, bool]: + import html import shutil + from slugify import slugify + from pfzy import fzy_scorer + from prompt_toolkit import Application + from prompt_toolkit.buffer import Buffer + from prompt_toolkit.layout import Layout, HSplit, Window, VSplit + from prompt_toolkit.layout.dimension import Dimension + from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl + from prompt_toolkit.key_binding import KeyBindings + from prompt_toolkit.styles import Style + from prompt_toolkit.shortcuts import print_formatted_text + from prompt_toolkit.formatted_text import HTML + + style = Style.from_dict(_STYLE_DICT) total = shutil.get_terminal_size().lines max_rows = max(3, int(total * max_fraction)) From c7fe0cda483da2acd88b0cbe423f22c03f1fe2ac Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sun, 15 Mar 2026 15:02:38 +0000 Subject: [PATCH 06/16] a better symbol for _hint from plan_ --- src/faff_cli/start.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 31cf6ba..d614bdf 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -73,7 +73,7 @@ def get_title_choices(all_sessions): for title, count in historical: choices.append(FuzzyItem(name=title, value=title, decoration=f"({count})")) for title in hints_only: - choices.append(FuzzyItem(name=title, value=title, decoration="→")) + choices.append(FuzzyItem(name=title, value=title, decoration="↑")) return choices @@ -121,7 +121,7 @@ def get_weighted_field_choices(field_name, alias, all_sessions, all_values, cont name = prettify_path_label(val) if not name: continue - choices.append(FuzzyItem(name=name, value=val, decoration=f"{val} →")) + choices.append(FuzzyItem(name=name, value=val, decoration=f"{val} ↑")) for val in others: name = prettify_path_label(val) From e04a37fe3be441841f76ece204b0279df3327d4e Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sun, 15 Mar 2026 15:26:36 +0000 Subject: [PATCH 07/16] fix a bug in fuzzy select, make number of trackers configurable --- src/faff_cli/config.py | 2 ++ src/faff_cli/start.py | 12 ++++++++---- src/faff_cli/ui/fuzzy_select.py | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/faff_cli/config.py b/src/faff_cli/config.py index 7d55004..35c5dfb 100644 --- a/src/faff_cli/config.py +++ b/src/faff_cli/config.py @@ -27,6 +27,7 @@ class SessionConfig: subject: bool = True trackers: bool = True note: bool = True + max_trackers: int = 0 # 0 = unlimited, 1 = at most one, etc. def enabled_fields(self) -> List[str]: """Return field names in canonical order, filtered to enabled ones.""" @@ -64,6 +65,7 @@ def load_config() -> CliConfig: subject=session_data.get("subject", True), trackers=session_data.get("trackers", True), note=session_data.get("note", True), + max_trackers=session_data.get("max_trackers", 0), ) return CliConfig(session=session) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index d614bdf..fe0262d 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -289,6 +289,10 @@ def _prompt_for_session_fields(ws, all_sessions): if "trackers" in enabled: tracker_names = plan_data["trackers"] all_tracker_ids = list(tracker_names.keys()) + max_trackers = cfg.max_trackers # 0 = unlimited + + def tracker_limit_reached(): + return max_trackers > 0 and len(trackers) >= max_trackers if all_tracker_ids: full_context = {"role": role_val, "impact": impact_val, "subject": subject_val, "mode": mode_val} @@ -313,7 +317,7 @@ def _prompt_for_session_fields(ws, all_sessions): name = tracker_names.get(tid, tid) suggested = FuzzyItem(name=name, value=tid, decoration=f"{tid} (suggested)") done_item = FuzzyItem(name="[No trackers]", value=None, decoration=None) - other_ids = [t for t in all_tracker_ids if t != tid] + other_ids = [] if tracker_limit_reached() else [t for t in all_tracker_ids if t != tid] other_choices, _ = get_weighted_tracker_choices( title, sessions_with_hints, other_ids, tracker_names, context=full_context, chosen_trackers=trackers, @@ -327,10 +331,10 @@ def _prompt_for_session_fields(ws, all_sessions): if picked and picked.value: trackers.append(picked.value) - elif len(auto_tracker_ids) > 1: + elif len(auto_tracker_ids) > 1 and not tracker_limit_reached(): # Multiple matches: prompt from the matching subset first auto_subset = [tid for tid in all_tracker_ids if tid in auto_tracker_ids] - while True: + while not tracker_limit_reached(): remaining_subset = [t for t in auto_subset if t not in trackers] if not remaining_subset: break @@ -348,7 +352,7 @@ def _prompt_for_session_fields(ws, all_sessions): break # Allow adding more (or full manual selection if no auto-match) - while True: + while not tracker_limit_reached(): if all(t in trackers for t in all_tracker_ids): break diff --git a/src/faff_cli/ui/fuzzy_select.py b/src/faff_cli/ui/fuzzy_select.py index b366c48..c54d6cc 100644 --- a/src/faff_cli/ui/fuzzy_select.py +++ b/src/faff_cli/ui/fuzzy_select.py @@ -140,8 +140,9 @@ def on_change(_): match_indices = {} else: scored = [] + query_lower = user_input.lower() for cand in normalised_choices: - score, idxs = fzy_scorer(user_input, cand.name) + score, idxs = fzy_scorer(query_lower, cand.name.lower()) if score > 0: scored.append((cand, score, idxs)) scored.sort(key=lambda x: x[1], reverse=True) From 7209f956e14c662a0d37f7cc8e09715e571b3f2a Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sun, 15 Mar 2026 16:57:47 +0000 Subject: [PATCH 08/16] make some improvements to status --- src/faff_cli/main.py | 48 ++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index 1f20171..1b28d0c 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -334,9 +334,13 @@ def push( else: typer.echo(f"No timesheet found for {aud.id} on {resolved_date}. Did you run 'faff compile' first?", err=True) else: - # No date provided - push all unsubmitted timesheets + # No date provided - push all unsubmitted timesheets for active audiences + active_audience_ids = {a.id for a in audiences} all_timesheets = ws.timesheets.list_timesheets() - unsubmitted = [ts for ts in all_timesheets if ts.meta.submitted_at is None] + unsubmitted = [ + ts for ts in all_timesheets + if ts.meta.submitted_at is None and ts.meta.audience_id in active_audience_ids + ] if audience: unsubmitted = [ts for ts in unsubmitted if ts.meta.audience_id == audience] @@ -352,7 +356,10 @@ def push( raise typer.Exit(1) @cli.command(rich_help_panel="Track your Time") -def status(ctx: typer.Context): +def status( + ctx: typer.Context, + show_stale: bool = typer.Option(False, "--show-stale", help="List stale timesheets in detail"), +): """ Show ledger status. @@ -364,6 +371,7 @@ def status(ctx: typer.Context): Examples: faff status + faff status --show-stale """ from rich.console import Console @@ -467,11 +475,14 @@ def status(ctx: typer.Context): console.print(f" {log_date} · [cyan]{hours:>4.1f}h[/cyan] → {', '.join(audience_ids)}") if stale_unsubmitted: - for ts in sorted(stale_unsubmitted, key=lambda t: t.date): - hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600 - console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [yellow]stale[/yellow]") + if show_stale: + for ts in sorted(stale_unsubmitted, key=lambda t: t.date): + hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600 + console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [yellow]stale[/yellow]") + else: + console.print(f" [yellow]{len(stale_unsubmitted)} stale[/yellow] [dim](run[/dim] [cyan]faff status --show-stale[/cyan] [dim]to list)[/dim]") - total_hours = sum(h for _, h, _ in needs_compiling) + sum(sum(s.duration.total_seconds() for s in ts.timeline) / 3600 for ts in stale_unsubmitted) + total_hours = sum(h for _, h, _ in needs_compiling) + sum(sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600 for ts in stale_unsubmitted) console.print(f" [dim]{total_needing_compile} log(s),[/dim] [cyan]{total_hours:.1f}h[/cyan] [dim]total[/dim]") # Show blockers after (unclosed sessions) @@ -490,8 +501,12 @@ def status(ctx: typer.Context): # 4. PUSH - Check what needs submission console.print("[bold]Timesheets to Push:[/bold]") + active_audience_ids = {aud.id for aud in audiences} failed = ws.timesheets.find_failed_submissions() - unsubmitted = [ts for ts in existing_timesheets if ts.meta.submitted_at is None] + unsubmitted = [ + ts for ts in existing_timesheets + if ts.meta.submitted_at is None and ts.meta.audience_id in active_audience_ids + ] # Exclude stale unsubmitted from the push list (they need recompiling first) stale_dates = {(ts.meta.audience_id, ts.date) for ts in stale_unsubmitted} @@ -503,7 +518,7 @@ def status(ctx: typer.Context): if unsubmitted_ready: console.print(f" [dim]Ready to Push. Run[/dim] [cyan]faff push[/cyan]:") for ts in sorted(unsubmitted_ready, key=lambda t: t.date): - hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600 + hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600 console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] → {ts.meta.audience_id}") # Show failed submissions @@ -512,7 +527,7 @@ def status(ctx: typer.Context): console.print() console.print(" [red]Failed.[/red] [dim]Fix errors and run[/dim] [cyan]faff push[/cyan]:") for ts in sorted(failed, key=lambda t: t.date): - hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600 + hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600 error = ts.meta.submission_error if len(error) > 50: error = error[:47] + "..." @@ -522,11 +537,14 @@ def status(ctx: typer.Context): if stale_submitted: if unsubmitted_ready or failed: console.print() - console.print(" [yellow]Stale (already submitted).[/yellow] [dim]Manual review needed:[/dim]") - for ts in sorted(stale_submitted, key=lambda t: t.date): - hours = sum(s.duration.total_seconds() for s in ts.timeline) / 3600 - submitted = ts.meta.submitted_at.strftime("%Y-%m-%d") - console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [dim]submitted {submitted}[/dim]") + if show_stale: + console.print(" [yellow]Stale (already submitted).[/yellow] [dim]Manual review needed:[/dim]") + for ts in sorted(stale_submitted, key=lambda t: t.date): + hours = sum(s.duration.total_seconds() for s in ts.timeline if s.end is not None) / 3600 + submitted = ts.meta.submitted_at.strftime("%Y-%m-%d") + console.print(f" {ts.date} · [cyan]{hours:>4.1f}h[/cyan] ({ts.meta.audience_id}) [dim]submitted {submitted}[/dim]") + else: + console.print(f" [yellow]{len(stale_submitted)} stale (already submitted)[/yellow] [dim](run[/dim] [cyan]faff status --show-stale[/cyan] [dim]to list)[/dim]") else: console.print(" [dim]✓ All timesheets submitted[/dim]") From ea32383348bee2121f0ac56d117f6762ed141367 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sun, 15 Mar 2026 21:20:24 +0000 Subject: [PATCH 09/16] report fields better --- src/faff_cli/field.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/faff_cli/field.py b/src/faff_cli/field.py index 317b4db..0f42bfb 100644 --- a/src/faff_cli/field.py +++ b/src/faff_cli/field.py @@ -87,14 +87,17 @@ def list( else: all_defined = set() - # Get intent counts from plans via Rust - intent_count = ws.plans.get_field_usage_stats(field) - # Get session counts and log dates from logs via Rust session_count, log_dates_dict = ws.logs.get_field_usage_stats(field) - # Combine all values: defined in plans + used in intents/sessions - values = all_defined | set(intent_count.keys()) | set(session_count.keys()) + # Combine all values: defined in plans + used in sessions + values = all_defined | set(session_count.keys()) + + # Build plan membership: value → source name (from value prefix, if in current plan) + plan_membership = {} + for value in all_defined: + source = value.split(":")[0] if ":" in value else "local" + plan_membership[value] = source # Convert log_dates_dict values (lists of PyDate) to count of unique logs log_count = {} @@ -109,13 +112,12 @@ def list( # Build field data list field_data = [] for value in values: - intents = intent_count.get(value, 0) sessions = session_count.get(value, 0) logs = log_count.get(value, 0) row = { "value": value, - "intents": intents, + "plan": plan_membership.get(value, ""), "sessions": sessions, "logs": logs, } @@ -131,7 +133,7 @@ def list( field_data = apply_filters(field_data, filters) # Sort by usage (most used first = most sessions) - field_data.sort(key=lambda x: (x["sessions"], x["intents"], x["logs"]), reverse=True) + field_data.sort(key=lambda x: (x["sessions"], x["logs"]), reverse=True) # Apply limit if limit: @@ -146,14 +148,14 @@ def list( columns = [ ("value", "Value", "cyan"), ("name", "Name", "yellow"), - ("intents", "Intents", "green"), + ("plan", "In Plan", "yellow"), ("sessions", "Sessions", "green"), ("logs", "Logs", "green"), ] else: columns = [ ("value", "Value", "cyan"), - ("intents", "Intents", "green"), + ("plan", "In Plan", "yellow"), ("sessions", "Sessions", "green"), ("logs", "Logs", "green"), ] From c24a2b8b426cbf1bb303692cef472e42676d5431 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 21 Mar 2026 10:42:35 +0000 Subject: [PATCH 10/16] Remove unsightly 'None's --- src/faff_cli/ui/fuzzy_select.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/faff_cli/ui/fuzzy_select.py b/src/faff_cli/ui/fuzzy_select.py index c54d6cc..26a9bfb 100644 --- a/src/faff_cli/ui/fuzzy_select.py +++ b/src/faff_cli/ui/fuzzy_select.py @@ -229,8 +229,9 @@ def get_input_width(): if selection: continue_marker = " (+)" if continue_selecting else "" + prompt_prefix = f"{prompt} " if prompt else "" print_formatted_text( - HTML(f"? {prompt} {html.escape(selection.name)}{' *NEW*' if selection and selection.is_new else ''}{continue_marker}") + HTML(f"? {prompt_prefix}{html.escape(selection.name)}{' *NEW*' if selection and selection.is_new else ''}{continue_marker}") ) return selection, continue_selecting \ No newline at end of file From 342c549302d46cf5e195e3418029db4e6dc9d6e9 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Mon, 6 Apr 2026 12:09:19 +0100 Subject: [PATCH 11/16] Support concurrent sessions --- src/faff_cli/main.py | 27 +++++++++++---------------- src/faff_cli/start.py | 7 +++++++ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index 1b28d0c..a6d737f 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -572,29 +572,24 @@ def stop(ctx: typer.Context): # Get the current log to see what we're stopping log = ws.logs.get_log(ws.today()) - active = log.active_session() + active = log.active_sessions() if not active: typer.echo("No active session to stop", err=True) raise typer.Exit(1) - # Capture the details before stopping - title = active.title - start_time = active.start + end_time = ws.now() - # Stop the session - ws.logs.stop_current_session() + # Stop all active sessions + ws.logs.stop_all_active_sessions() - # Calculate duration - end_time = ws.now() - duration = end_time - start_time - duration_minutes = int(duration.total_seconds() / 60) - - # Show feedback - typer.echo(f"Stopped '{title}'") - typer.echo(f" Started: {start_time.strftime('%H:%M')}") - typer.echo(f" Ended: {end_time.strftime('%H:%M')}") - typer.echo(f" Duration: {duration_minutes} minutes") + # Show feedback for each stopped session + for session in active: + duration_minutes = int((end_time - session.start).total_seconds() / 60) + typer.echo(f"Stopped '{session.title}'") + typer.echo(f" Started: {session.start.strftime('%H:%M')}") + typer.echo(f" Ended: {end_time.strftime('%H:%M')}") + typer.echo(f" Duration: {duration_minutes} minutes") except Exception as e: typer.echo(f"Error stopping session: {e}", err=True) raise typer.Exit(1) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index fe0262d..08ca2e2 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -378,6 +378,7 @@ def start( ctx: typer.Context, since: str = typer.Option(None, "--since", help="Start time (e.g., '14:30', 'now')"), continue_from_last: bool = typer.Option(False, "--continue", "-c", help="Start at the end of the previous session"), + concurrent: bool = typer.Option(False, "--concurrent", help="Start alongside any active sessions without stopping them"), ): """Start a new task or activity.""" try: @@ -409,6 +410,12 @@ def start( cfg = load_config().session note = input("? Note for this session (optional): ") if cfg.note else None + # For sequential start (default), stop any active sessions first + if not concurrent: + log = ws.logs.get_log(date) + if log.active_sessions(): + ws.logs.stop_all_active_sessions() + ws.logs.start_session( title=title, role=role, From c8a2b78c55745fa0c4df31f9961614224a3126e6 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Mon, 6 Apr 2026 12:25:27 +0100 Subject: [PATCH 12/16] Manage mulitple active sessions --- src/faff_cli/main.py | 65 +++++++++++++++++++++++++---------------- src/faff_cli/session.py | 2 ++ 2 files changed, 42 insertions(+), 25 deletions(-) diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index a6d737f..0a7d352 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -191,7 +191,7 @@ def compile( # FIXME: This check should be in faff-core, not faff-cli # The compile_time_sheet method should refuse to compile logs with active sessions # Check for unclosed session - if log.active_session(): + if log.active_sessions(): typer.echo(f"Cannot compile {resolved_date}: log has an unclosed session. Run 'faff stop' first.", err=True) raise typer.Exit(1) @@ -236,7 +236,7 @@ def compile( # FIXME: This check should be in faff-core, not faff-cli # The compile_time_sheet method should refuse to compile logs with active sessions # Check for unclosed session - if log.active_session(): + if log.active_sessions(): skipped_unclosed.append(log_date) continue @@ -421,13 +421,15 @@ def status( else: console.print(f" {minutes}m tracked") - active_session = log.active_session() - if active_session: - duration_minutes = int(active_session.elapsed(ws.now()).total_seconds() / 60) - if active_session.note: - console.print(f" [green]●[/green] {active_session.title} [dim]({active_session.note})[/dim] · {duration_minutes}m") - else: - console.print(f" [green]●[/green] {active_session.title} · {duration_minutes}m") + active_sessions = log.active_sessions() + if active_sessions: + for active_session in active_sessions: + duration_minutes = int(active_session.elapsed(ws.now()).total_seconds() / 60) + short_id = active_session.id[:8] if active_session.id else "" + if active_session.note: + console.print(f" [green]●[/green] {active_session.title} [dim]({active_session.note})[/dim] · {duration_minutes}m [dim]{short_id}[/dim]") + else: + console.print(f" [green]●[/green] {active_session.title} · {duration_minutes}m [dim]{short_id}[/dim]") else: console.print(" [dim]○ Not tracking[/dim]") @@ -458,7 +460,7 @@ def status( if needs_compile_for_audiences: total_hours = log.total_recorded_time().total_seconds() / 3600 - if log.active_session(): + if log.active_sessions(): has_unclosed.append((log_date, total_hours, needs_compile_for_audiences)) else: needs_compiling.append((log_date, total_hours, needs_compile_for_audiences)) @@ -557,20 +559,23 @@ def status( cli.add_typer(sql.app, name="sql", rich_help_panel="Track your Time") @cli.command(rich_help_panel="Track your Time") -def stop(ctx: typer.Context): +def stop( + ctx: typer.Context, + session_id: str = typer.Argument(None, help="ID prefix of the session to stop (omit to stop all active sessions)"), +): """ - Stop the current session. + Stop active session(s). - Ends the currently active time tracking session. + With no argument, stops all active sessions. Pass a session ID prefix + (as shown in 'faff status' or 'faff session list') to stop a specific one. Examples: faff stop + faff stop a3f9c12b """ try: - import humanize ws: Workspace = ctx.obj - # Get the current log to see what we're stopping log = ws.logs.get_log(ws.today()) active = log.active_sessions() @@ -580,16 +585,26 @@ def stop(ctx: typer.Context): end_time = ws.now() - # Stop all active sessions - ws.logs.stop_all_active_sessions() - - # Show feedback for each stopped session - for session in active: - duration_minutes = int((end_time - session.start).total_seconds() / 60) - typer.echo(f"Stopped '{session.title}'") - typer.echo(f" Started: {session.start.strftime('%H:%M')}") - typer.echo(f" Ended: {end_time.strftime('%H:%M')}") - typer.echo(f" Duration: {duration_minutes} minutes") + if session_id: + matches = [s for s in active if s.id.startswith(session_id)] + if not matches: + typer.echo(f"No active session with id starting '{session_id}'", err=True) + raise typer.Exit(1) + ws.logs.stop_session(session_id) + for session in matches: + duration_minutes = int((end_time - session.start).total_seconds() / 60) + typer.echo(f"Stopped '{session.title}'") + typer.echo(f" Started: {session.start.strftime('%H:%M')}") + typer.echo(f" Ended: {end_time.strftime('%H:%M')}") + typer.echo(f" Duration: {duration_minutes} minutes") + else: + ws.logs.stop_all_active_sessions() + for session in active: + duration_minutes = int((end_time - session.start).total_seconds() / 60) + typer.echo(f"Stopped '{session.title}'") + typer.echo(f" Started: {session.start.strftime('%H:%M')}") + typer.echo(f" Ended: {end_time.strftime('%H:%M')}") + typer.echo(f" Duration: {duration_minutes} minutes") except Exception as e: typer.echo(f"Error stopping session: {e}", err=True) raise typer.Exit(1) diff --git a/src/faff_cli/session.py b/src/faff_cli/session.py index b28c31a..f800ee8 100644 --- a/src/faff_cli/session.py +++ b/src/faff_cli/session.py @@ -112,6 +112,7 @@ def list_sessions( session_data.append({ "date": str(log.date), "date_obj": log.date, + "id": session.id[:8] if session.id else "", "start": session.start.strftime("%H:%M:%S"), "end": session.end.strftime("%H:%M:%S") if session.end else "active", "title": session.title, @@ -143,6 +144,7 @@ def list_sessions( # Define columns for table output columns = [ ("date", "Date", "cyan"), + ("id", "ID", "dim"), ("start", "Start", None), ("end", "End", None), ("title", "Title", "yellow"), From 9c1fc83c3e63944dac184742aee7e94540489f99 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Tue, 7 Apr 2026 22:09:55 +0100 Subject: [PATCH 13/16] Added a beautifully-rendering but vibecoded to heck timeline view. In need of a serious look before it is released --- src/faff_cli/main.py | 3 +- src/faff_cli/timeline.py | 347 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/faff_cli/timeline.py diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index 0a7d352..6ce419e 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -1,6 +1,6 @@ import typer -from faff_cli import log, id, plan, start, timesheet, field, remote, plugin, reflect, session, sql, __version__ +from faff_cli import log, id, plan, start, timesheet, field, remote, plugin, reflect, session, sql, timeline, __version__ from faff_cli.utils import edit_file import faff_core @@ -13,6 +13,7 @@ cli.add_typer(start.app, name="start", rich_help_panel="Track your Time") cli.add_typer(session.app, name="session", rich_help_panel="Track your Time") cli.add_typer(reflect.app, name="reflect", rich_help_panel="Track your Time") +cli.add_typer(timeline.app, name="timeline", rich_help_panel="Track your Time") # Compile and Submit Timesheets cli.add_typer(timesheet.app, name="timesheet", rich_help_panel="Compile and Submit Timesheets") diff --git a/src/faff_cli/timeline.py b/src/faff_cli/timeline.py new file mode 100644 index 0000000..b34a1d7 --- /dev/null +++ b/src/faff_cli/timeline.py @@ -0,0 +1,347 @@ +import math +import typer +from rich.console import Console + +from faff_core import Workspace + +app = typer.Typer(help="Show a visual timeline of the day's sessions.") + +MAX_HEIGHT = 5 # max connector/gap lines between events + + +@app.callback(invoke_without_command=True) +def timeline( + ctx: typer.Context, + date: str = typer.Argument(None), +): + """ + Show a visual timeline of the day's sessions. + + Concurrent sessions are shown in parallel columns. Session and gap heights + are scaled proportionally (sqrt) so longer durations appear taller. + + Examples: + faff timeline + faff timeline yesterday + faff timeline 2026-03-30 + """ + ws: Workspace = ctx.obj + console = Console() + resolved_date = ws.parse_natural_date(date) + log = ws.logs.get_log(resolved_date) + now = ws.now() + + sessions = sorted(log.timeline, key=lambda s: s.start) + + if not sessions: + console.print("[dim]No sessions recorded.[/dim]") + return + + # --- Lane assignment --- + lane_ends = [] + lane_assignments = {} # session.id -> lane index + + for s in sessions: + s_end = s.end or now + lane = next((i for i, e in enumerate(lane_ends) if e <= s.start), len(lane_ends)) + if lane == len(lane_ends): + lane_ends.append(s_end) + else: + lane_ends[lane] = s_end + lane_assignments[s.id] = lane + + num_lanes = len(lane_ends) + + # --- Session colours --- + palette = ['green', 'cyan', 'magenta', 'yellow', 'blue'] + session_colors = {s.id: palette[i % len(palette)] for i, s in enumerate(sessions)} + + # --- Build event list --- + # 'active' events represent still-running sessions pinned to now. + # Sort order at equal times: ends first, then actives, then starts. + events = [] + for s in sessions: + events.append((s.start, 'start', s)) + if s.end: + events.append((s.end, 'end', s)) + else: + events.append((now, 'active', s)) + + def event_sort_key(e): + time, type_, session = e + # Zero-duration sessions (end == start): sort start before end so the + # session is in active_ids when its end event fires. + # All other cases: ends before starts so handoffs remove the outgoing + # session before adding the incoming one. + is_zero_dur = type_ in ('start', 'end') and session.end and session.end == session.start + if is_zero_dur: + return (time, {'start': 0, 'end': 1}[type_]) + return (time, {'end': 0, 'active': 1, 'start': 2}[type_]) + + events.sort(key=event_sort_key) + + # --- Helpers --- + def fmt_dur(total_seconds): + minutes = int(total_seconds // 60) + h, m = divmod(minutes, 60) + if h and m: + return f"{h}h {m}m" + if h: + return f"{h}h" + return f"{m}m" + + def lane_color(lane_idx, active_sids): + for sid in active_sids: + if lane_assignments.get(sid) == lane_idx: + return session_colors.get(sid) + return None + + def make_lane_str(active, event_lane=None, event_char=None): + parts = [] + for i in range(num_lanes): + if i == event_lane: + parts.append(event_char) + elif any(lane_assignments[sid] == i for sid in active): + c = lane_color(i, active) + parts.append(f'[{c}]│[/{c}]' if c else '│') + else: + parts.append(' ') + return ''.join(parts) + + # --- Build render plan (two-pass for sqrt scaling) --- + # Pass 1: collect all inter-event segments with type and duration + plan = [] + active_ids = set() + last_time = None + + for event_time, event_type, session in events: + sid = session.id + + if last_time is not None and event_time != last_time: + seg_secs = (event_time - last_time).total_seconds() + if active_ids: + plan.append(('connector', seg_secs, frozenset(active_ids))) + elif seg_secs > 0: + plan.append(('gap', seg_secs, None)) + + plan.append(('event', event_time, event_type, session)) + + if event_type == 'start': + active_ids.add(sid) + elif event_type == 'end': + active_ids.discard(sid) + + last_time = event_time + + # Pass 2: find max segment duration for sqrt scaling + seg_durations = [item[1] for item in plan if item[0] in ('connector', 'gap')] + max_secs = max(seg_durations) if seg_durations else 1.0 + + def scaled_height(secs): + return max(1, round(math.sqrt(secs / max_secs) * MAX_HEIGHT)) + + # Pre-process: annotate end events that lead into a gap with the gap duration, + # and mark those gap items as consumed so they aren't rendered separately. + consumed = set() + for i, item in enumerate(plan): + if item[0] == 'event' and item[2] == 'end': + j = i + 1 + while j < len(plan) and plan[j][0] == 'connector': + j += 1 + if j < len(plan) and plan[j][0] == 'gap': + plan[i] = item + (plan[j][1],) # append break_secs + consumed.add(j) + + # Pass 3: render + active_ids = set() + prev_shown_time = None + sep = " [dim]·[/dim] " + + for idx, item in enumerate(plan): + if idx in consumed: + if item[0] == 'gap': + # Gap label moved to the preceding ● line; still render ┊ height lines + _, gap_secs, _ = item + height = scaled_height(gap_secs) + for _ in range(height): + console.print(f" [dim]┊[/dim]") + # active/start events consumed by lookahead: skip entirely + continue + + if item[0] == 'connector': + _, seg_secs, frozen_active = item + height = scaled_height(seg_secs) + lane_str = make_lane_str(frozen_active) + for _ in range(height): + console.print(f" {lane_str}") + + elif item[0] == 'gap': + _, gap_secs, _ = item + height = scaled_height(gap_secs) + for _ in range(height): + console.print(f" [dim]┊[/dim]") + + else: # event + _, event_time, event_type, session = item[:4] + sid = session.id + color = session_colors[sid] + time_str = event_time.strftime('%H:%M') if event_time != prev_shown_time else ' ' + + if event_type == 'start': + # Collect all simultaneous start events + starts_now = [session] + j = idx + 1 + while j < len(plan) and plan[j][0] == 'event' and plan[j][2] == 'start' and plan[j][1] == event_time: + starts_now.append(plan[j][3]) + consumed.add(j) + j += 1 + # Build lane string with ● for each starting lane + parts = [] + for i in range(num_lanes): + s_in_lane = next((s for s in starts_now if lane_assignments.get(s.id) == i), None) + if s_in_lane: + c = session_colors[s_in_lane.id] + parts.append(f'[{c}]●[/{c}]') + elif any(lane_assignments[s2] == i for s2 in active_ids): + c = lane_color(i, active_ids) + parts.append(f'[{c}]│[/{c}]' if c else '│') + else: + parts.append(' ') + lane_str = ''.join(parts) + for s in starts_now: + active_ids.add(s.id) + # First label inline with the dots; subsequent labels on connector lines below + for i, s in enumerate(starts_now): + c = session_colors[s.id] + if s.end: + elapsed = (s.end - s.start).total_seconds() + dur_str = f"[white]{fmt_dur(elapsed)}[/white]" + else: + elapsed = (now - s.start).total_seconds() + dur_str = f"[white]{fmt_dur(elapsed)}…[/white]" + label = f"[{c}]{s.title or '(untitled)'}[/{c}]{sep}{dur_str}{sep}[dim]{s.id[:8]}[/dim]" + if i == 0: + console.print(f" {time_str} {lane_str} {label}") + else: + connector = make_lane_str(active_ids) + console.print(f" {connector} {label}") + prev_shown_time = event_time + + elif event_type == 'end': + # Collect all simultaneous end events + ends_now = [session] + j = idx + 1 + while j < len(plan) and plan[j][0] == 'event' and plan[j][2] == 'end' and plan[j][1] == event_time: + ends_now.append(plan[j][3]) + consumed.add(j) + j += 1 + # Build lane string with ● for each ending lane + parts = [] + for i in range(num_lanes): + s_in_lane = next((s for s in ends_now if lane_assignments.get(s.id) == i), None) + if s_in_lane: + c = session_colors[s_in_lane.id] + parts.append(f'[{c}]●[/{c}]') + elif any(lane_assignments[s2] == i for s2 in active_ids if s2 not in {s.id for s in ends_now}): + c = lane_color(i, active_ids) + parts.append(f'[{c}]│[/{c}]' if c else '│') + else: + parts.append(' ') + lane_str = ''.join(parts) + for s in ends_now: + active_ids.discard(s.id) + if active_ids: + console.print(f" {time_str} {lane_str}") + else: + break_secs = item[4] if len(item) > 4 else None + if break_secs is not None: + console.print(f" {time_str} {lane_str} [white]Break[/white]{sep}[white]{fmt_dur(break_secs)}[/white]") + else: + # Check for sequential handoff (start at same time) + next_starts = [] + k = idx + 1 + while k < len(plan) and plan[k][0] == 'event' and plan[k][2] == 'start' and plan[k][1] == event_time: + next_starts.append(plan[k][3]) + consumed.add(k) + k += 1 + if next_starts: + for s in next_starts: + active_ids.add(s.id) + for i, s in enumerate(next_starts): + c = session_colors[s.id] + if s.end: + elapsed = (s.end - s.start).total_seconds() + dur_str = f"[white]{fmt_dur(elapsed)}[/white]" + else: + elapsed = (now - s.start).total_seconds() + dur_str = f"[white]{fmt_dur(elapsed)}…[/white]" + label = f"[{c}]{s.title or '(untitled)'}[/{c}]{sep}{dur_str}{sep}[dim]{s.id[:8]}[/dim]" + parts = [] + for li in range(num_lanes): + s_in_ends = next((e for e in ends_now if lane_assignments.get(e.id) == li), None) + s_in_starts = next((st for st in next_starts if lane_assignments.get(st.id) == li), None) + if s_in_starts: + lc = session_colors[s_in_starts.id] + parts.append(f'[{lc}]●[/{lc}]') + elif s_in_ends: + lc = session_colors[s_in_ends.id] + parts.append(f'[{lc}]●[/{lc}]') + elif any(lane_assignments[s2] == li for s2 in active_ids if s2 not in {s.id for s in next_starts}): + lc = lane_color(li, active_ids) + parts.append(f'[{lc}]│[/{lc}]' if lc else '│') + else: + parts.append(' ') + merged_lane = ''.join(parts) + if i == 0: + console.print(f" {time_str} {merged_lane} {label}") + else: + connector = make_lane_str(active_ids) + console.print(f" {connector} {label}") + else: + console.print(f" {time_str} {lane_str} [white]End[/white]") + prev_shown_time = event_time + + else: # active — collect all simultaneous active events and render on one line + active_now = [session] + j = idx + 1 + while j < len(plan) and plan[j][0] == 'event' and plan[j][2] == 'active' and plan[j][1] == event_time: + active_now.append(plan[j][3]) + consumed.add(j) + j += 1 + parts = [] + for i in range(num_lanes): + s_in_lane = next((s for s in active_now if lane_assignments.get(s.id) == i), None) + if s_in_lane: + c = session_colors[s_in_lane.id] + parts.append(f'[{c}]○[/{c}]') + elif any(lane_assignments[s2] == i for s2 in active_ids if s2 not in {s.id for s in active_now}): + c = lane_color(i, active_ids) + parts.append(f'[{c}]│[/{c}]' if c else '│') + else: + parts.append(' ') + lane_str = ''.join(parts) + for s in active_now: + active_ids.discard(s.id) + console.print(f" {time_str} {lane_str} [dim]Now[/dim]") + prev_shown_time = event_time + + # Summary line + summary = log.summary(now) + total_secs = sum(((s.end or now) - s.start).total_seconds() for s in sessions) + total = fmt_dur(total_secs) + + # Elapsed = union of session intervals (excludes breaks, overlaps counted once) + merged = [] + for s in sorted(sessions, key=lambda s: s.start): + s_end = s.end or now + if merged and s.start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], s_end)) + else: + merged.append((s.start, s_end)) + elapsed_secs = sum((e - s).total_seconds() for s, e in merged) + wallclock = fmt_dur(elapsed_secs) + console.print() + if summary['has_concurrent_sessions']: + console.print(f" [dim]{total} tracked{sep}{wallclock} elapsed (concurrent sessions)[/dim]") + else: + console.print(f" [dim]{total} tracked{sep}{wallclock} elapsed[/dim]") From 939a2ffdf38a118d6ecf3f3ee50386a6a6fa4d5f Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 11 Apr 2026 01:21:53 +0100 Subject: [PATCH 14/16] Add a _continue_ command to start a new session with the properties of a previous sessoin --- src/faff_cli/main.py | 61 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/faff_cli/main.py b/src/faff_cli/main.py index 6ce419e..7da689c 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -1,4 +1,5 @@ import typer +from typing import Optional from faff_cli import log, id, plan, start, timesheet, field, remote, plugin, reflect, session, sql, timeline, __version__ from faff_cli.utils import edit_file @@ -14,6 +15,7 @@ cli.add_typer(session.app, name="session", rich_help_panel="Track your Time") cli.add_typer(reflect.app, name="reflect", rich_help_panel="Track your Time") cli.add_typer(timeline.app, name="timeline", rich_help_panel="Track your Time") +cli.add_typer(timeline.app, name="tl", hidden=True) # Compile and Submit Timesheets cli.add_typer(timesheet.app, name="timesheet", rich_help_panel="Compile and Submit Timesheets") @@ -559,6 +561,65 @@ def status( cli.add_typer(log.app, name="log", rich_help_panel="Track your Time") cli.add_typer(sql.app, name="sql", rich_help_panel="Track your Time") +@cli.command(name="continue", rich_help_panel="Track your Time") +def continue_session( + ctx: typer.Context, + session_id: str = typer.Argument(..., help="ID prefix of the session to continue"), + concurrent: bool = typer.Option(False, "--concurrent", help="Start alongside any active sessions without stopping them"), + since: Optional[str] = typer.Option(None, "--since", help="Start time for the new session (e.g. '12:50', '14:30')"), +): + """ + Continue a previous session with the same properties. + + Looks up a session by ID prefix and starts a new session with the same + title, role, impact, mode, subject, and trackers, starting now (or at --since). + + Examples: + faff continue a3f9c12b + faff continue a3f9c12b --concurrent + faff continue a3f9c12b --since 12:50 + """ + try: + ws: Workspace = ctx.obj + log = ws.logs.get_log(ws.today()) + + matches = [s for s in log.timeline if s.id.startswith(session_id)] + if not matches: + typer.echo(f"No session found with id starting '{session_id}'", err=True) + raise typer.Exit(1) + if len(matches) > 1: + typer.echo(f"Ambiguous id '{session_id}' matches {len(matches)} sessions", err=True) + raise typer.Exit(1) + + source = matches[0] + start_time = ws.parse_natural_datetime(since) if since else ws.now() + + if not concurrent and log.active_sessions(): + if since: + updated_log = log.stop_all_active_sessions(start_time) + trackers = ws.plans.get_trackers(ws.today()) + ws.logs.write_log(updated_log, trackers) + else: + ws.logs.stop_all_active_sessions() + + ws.logs.start_session( + title=source.title, + role=source.role, + impact=source.impact, + mode=source.mode, + subject=source.subject, + trackers=source.trackers, + start_time=start_time, + note=None, + ) + typer.echo(f"Continuing '{source.title}' at {start_time.strftime('%H:%M')}") + except typer.Exit: + raise + except Exception as e: + typer.echo(f"Error continuing session: {e}", err=True) + raise typer.Exit(1) + + @cli.command(rich_help_panel="Track your Time") def stop( ctx: typer.Context, From eb59583e3a3548d2df961e2d15e1c9ca29db1e11 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 11 Apr 2026 01:23:05 +0100 Subject: [PATCH 15/16] Fix bug not applying --since time all active sessions under concurrency --- src/faff_cli/start.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 08ca2e2..bf74b1b 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -414,7 +414,12 @@ def start( if not concurrent: log = ws.logs.get_log(date) if log.active_sessions(): - ws.logs.stop_all_active_sessions() + if since: + updated_log = log.stop_all_active_sessions(start_time) + trackers = ws.plans.get_trackers(date) + ws.logs.write_log(updated_log, trackers) + else: + ws.logs.stop_all_active_sessions() ws.logs.start_session( title=title, From 613c885d8320ef7b77effba119878ab7a1abf677 Mon Sep 17 00:00:00 2001 From: Thomas Lant Date: Sat, 11 Apr 2026 01:23:28 +0100 Subject: [PATCH 16/16] More fiddling with timeline output --- src/faff_cli/timeline.py | 41 +++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/faff_cli/timeline.py b/src/faff_cli/timeline.py index b34a1d7..90d8ed7 100644 --- a/src/faff_cli/timeline.py +++ b/src/faff_cli/timeline.py @@ -119,10 +119,11 @@ def make_lane_str(active, event_lane=None, event_char=None): if last_time is not None and event_time != last_time: seg_secs = (event_time - last_time).total_seconds() - if active_ids: - plan.append(('connector', seg_secs, frozenset(active_ids))) - elif seg_secs > 0: - plan.append(('gap', seg_secs, None)) + if seg_secs >= 60: # suppress sub-minute segments (e.g. now vs session end) + if active_ids: + plan.append(('connector', seg_secs, frozenset(active_ids))) + else: + plan.append(('gap', seg_secs, None)) plan.append(('event', event_time, event_type, session)) @@ -146,7 +147,11 @@ def scaled_height(secs): for i, item in enumerate(plan): if item[0] == 'event' and item[2] == 'end': j = i + 1 - while j < len(plan) and plan[j][0] == 'connector': + # Skip past connectors and simultaneous end events at the same time + while j < len(plan) and ( + plan[j][0] == 'connector' or + (plan[j][0] == 'event' and plan[j][2] == 'end' and plan[j][1] == item[1]) + ): j += 1 if j < len(plan) and plan[j][0] == 'gap': plan[i] = item + (plan[j][1],) # append break_secs @@ -185,7 +190,8 @@ def scaled_height(secs): _, event_time, event_type, session = item[:4] sid = session.id color = session_colors[sid] - time_str = event_time.strftime('%H:%M') if event_time != prev_shown_time else ' ' + time_fmt = event_time.strftime('%H:%M') + time_str = time_fmt if time_fmt != prev_shown_time else ' ' if event_type == 'start': # Collect all simultaneous start events @@ -225,7 +231,7 @@ def scaled_height(secs): else: connector = make_lane_str(active_ids) console.print(f" {connector} {label}") - prev_shown_time = event_time + prev_shown_time = time_fmt elif event_type == 'end': # Collect all simultaneous end events @@ -254,8 +260,21 @@ def scaled_height(secs): console.print(f" {time_str} {lane_str}") else: break_secs = item[4] if len(item) > 4 else None + # Build a full-width lane string: colored ● for ending lanes, + # plain white ● at lane 0 if nothing ends there (anchors ┊ alignment). + be_parts = [] + for i in range(num_lanes): + s_in_ends = next((s for s in ends_now if lane_assignments.get(s.id) == i), None) + if s_in_ends: + c = session_colors[s_in_ends.id] + be_parts.append(f'[{c}]●[/{c}]') + elif i == 0: + be_parts.append('●') + else: + be_parts.append(' ') + be_lane_str = ''.join(be_parts) if break_secs is not None: - console.print(f" {time_str} {lane_str} [white]Break[/white]{sep}[white]{fmt_dur(break_secs)}[/white]") + console.print(f" {time_str} {be_lane_str} [white]Break[/white]{sep}[white]{fmt_dur(break_secs)}[/white]") else: # Check for sequential handoff (start at same time) next_starts = [] @@ -298,8 +317,8 @@ def scaled_height(secs): connector = make_lane_str(active_ids) console.print(f" {connector} {label}") else: - console.print(f" {time_str} {lane_str} [white]End[/white]") - prev_shown_time = event_time + console.print(f" {time_str} {be_lane_str} [white]End[/white]") + prev_shown_time = time_fmt else: # active — collect all simultaneous active events and render on one line active_now = [session] @@ -323,7 +342,7 @@ def scaled_height(secs): for s in active_now: active_ids.discard(s.id) console.print(f" {time_str} {lane_str} [dim]Now[/dim]") - prev_shown_time = event_time + prev_shown_time = time_fmt # Summary line summary = log.summary(now)