diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 25c5657..7a170ee 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -1,13 +1,11 @@ import typer -from typing import Sequence, List +from collections import defaultdict from titlecase import titlecase - 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.") @@ -28,133 +26,231 @@ def prettify_path_label(path: str) -> str: return f"{name} ({namespace}:{path})" if context else name -def nicer(strings: Sequence[str]) -> list[str | FuzzyItem]: +def nicer(strings) -> list[FuzzyItem]: return [ FuzzyItem(name=prettify_path_label(s), value=s, decoration=s) for s in strings ] -def nicer_tracker(strings: Sequence[str], ws: Workspace) -> list[str | FuzzyItem]: - trackers = ws.plans.get_trackers(ws.today()) - return [ - FuzzyItem(name=trackers.get(s, ''), value=s, decoration=s) - for s in strings + +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] + 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) ] + return matching if matching else alias_matching -def print_sentence(role=None, action=None, objective=None, subject=None): - """Print the intent sentence with filled/unfilled parts.""" - role_str = f"[bold cyan]{prettify_path_label(role)}[/bold cyan]" if role else "[dim]________[/dim]" - action_str = f"[bold cyan]{prettify_path_label(action)}[/bold cyan]" if action else "[dim]________[/dim]" - objective_str = f"[bold cyan]{prettify_path_label(objective)}[/bold cyan]" if objective else "[dim]________[/dim]" - subject_str = f"[bold cyan]{prettify_path_label(subject)}[/bold cyan]" if subject else "[dim]________[/dim]" - from rich.console import Console - console = Console() +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) - sentence = f" [dim]→[/dim] As a {role_str}, I am {action_str} to achieve {objective_str}, focused on {subject_str}." - console.print(f"\n{sentence}\n") + sorted_aliases = sorted(alias_totals.items(), key=lambda x: x[1], reverse=True) -def input_new_intent(alias: str, ws: Workspace) -> Intent: - """ - Prompt the user for details to create a new intent. + choices = [] + for alias, count in sorted_aliases: + decoration = f"({count})" if count > 0 else None + choices.append(FuzzyItem(name=alias, value=alias, decoration=decoration)) + + return choices + + +def get_weighted_field_choices(field_name, alias, all_intents, all_values, session_counts_by_id, context=None): + """Build weighted FuzzyItem list for a field, conditioned on alias and any previously chosen fields.""" + matching = _filter_intents(all_intents, alias, context) + + field_freq = defaultdict(int) + for intent in matching: + val = getattr(intent, field_name, None) + if val: + field_freq[val] += session_counts_by_id.get(intent.intent_id, 0) + + 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] + + choices = [] + for val, count in correlated: + name = prettify_path_label(val) + if not name: + continue + decoration = f"{val} ({count})" if count > 0 else val + choices.append(FuzzyItem(name=name, value=val, decoration=decoration)) + + for val in others: + name = prettify_path_label(val) + if not name: + continue + choices.append(FuzzyItem(name=name, value=val, decoration=val)) + + return choices + + +def get_weighted_tracker_choices(alias, all_intents, all_tracker_ids, tracker_names, session_counts_by_id, 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. """ - from rich.console import Console - console = Console() - date = ws.today() + matching = _filter_intents(all_intents, alias, context) - console.print() - console.print("[bold green]✓[/bold green] Great! Now, let's capture the details.", style="bold") - console.print("[dim]Complete this sentence to describe what you're doing:[/dim]") - print_sentence() + 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) - console.print("[bold]What role are you performing here?[/bold]") - console.print("[dim]e.g. Line Manager, Pre-Sales Engineer, Parent[/dim]") - role, _ = fuzzy_select( - "Role:", - nicer([x for x in ws.plans.get_roles(date)]), - escapable=True - ) - print_sentence(role=role.value if role else None) - - console.print("[bold]What action are you doing?[/bold]") - console.print("[dim]e.g. Planning, Attending a Scheduled Meeting[/dim]") - action, _ = fuzzy_select( - "Action:", - nicer([x for x in ws.plans.get_actions(date)]), - escapable=True - ) - print_sentence(role=role.value if role else None, action=action.value if action else None) - - console.print("[bold]What outcome are you aiming for?[/bold]") - console.print("[dim]e.g. Career Development, New Revenue New Business[/dim]") - objective, _ = fuzzy_select( - "Outcome:", - nicer([x for x in ws.plans.get_objectives(date)]), - escapable=True - ) - print_sentence(role=role.value if role else None, action=action.value if action else None, objective=objective.value if objective else None) - - console.print("[bold]Who or what are you focused on here?[/bold]") - console.print("[dim]e.g. John Smith, ACME Corporation, Sales Department[/dim]") - subject, _ = fuzzy_select( - "Focus:", - nicer([x for x in ws.plans.get_subjects(date)]), - escapable=True - ) - print_sentence(role=role.value if role else None, action=action.value if action else None, objective=objective.value if objective else None, subject=subject.value if subject else None) + chosen_set = set(chosen_trackers or []) + remaining = [t for t in all_tracker_ids if t not in chosen_set] - trackers: List[str] = [] - all_trackers = list(ws.plans.get_trackers(date)) + correlated = sorted( + [(t, tracker_freq[t]) for t in remaining if t in tracker_freq], + key=lambda x: x[1], reverse=True + ) + correlated_set = {t for t, _ in correlated} + others = [t for t in remaining if t not in correlated_set] - # Only show tracker selection if there are trackers available - if all_trackers: - ingesting_trackers = True + choices = [] + for tracker_id, count in correlated: + name = tracker_names.get(tracker_id, tracker_id) + decoration = f"{tracker_id} ({count})" if count > 0 else tracker_id + choices.append(FuzzyItem(name=name, value=tracker_id, decoration=decoration)) + for tracker_id in others: + name = tracker_names.get(tracker_id, tracker_id) + choices.append(FuzzyItem(name=name, value=tracker_id, decoration=tracker_id)) - console.print("[bold]Add remote trackers?[/bold]") - console.print("[dim]Select trackers or press Enter on empty line to finish.[/dim]") + return choices, bool(correlated) - while ingesting_trackers: - remaining = [x for x in all_trackers if x not in trackers] - if not remaining: - break - # Add a "Done" option at the top - choices = [FuzzyItem(name="[Done]", value=None, decoration="")] + nicer_tracker(remaining, ws) +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) - tracker_id, _ = fuzzy_select( - prompt="Tracker:", - choices=choices, - escapable=True, - create_new=False - ) - if tracker_id and tracker_id.value: - trackers.append(tracker_id.value) - else: - ingesting_trackers = False + 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.value if role else None, - objective=objective.value if objective else None, - action=action.value if action else None, - subject=subject.value if subject else None, + 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 from the plan - it now has a generated ID - intent_with_id = [i for i in new_plan.intents if i.alias == alias][-1] + # Get the intent back with its generated ID + return [i for i in new_plan.intents if i.alias == alias][-1] - # Show final success message with complete intent + +def _prompt_for_intent(ws, existing_intents, session_counts): + """Interactively prompt for all intent fields. Returns the matched/created Intent, or None if aborted.""" + from rich.console import Console + console = Console() + date = ws.today() + + # Step 1: Alias console.print() - console.print("[bold green]✓[/bold green] Intent created!", style="bold") - print_sentence(role=role.value if role else None, action=action.value if action else None, objective=objective.value if objective else None, subject=subject.value if subject else None) + 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( + prompt="Title:", + choices=get_alias_choices(existing_intents, session_counts), + escapable=False, + slugify_new=False, + ) + if not chosen_alias: + return None + alias = chosen_alias.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, existing_intents, + list(ws.plans.get_roles(date)), session_counts, + ), escapable=True) + role_val = role.value if role else None + + # Step 3: Impact (objective in data model) + 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, existing_intents, + list(ws.plans.get_objectives(date)), session_counts, + 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", alias, existing_intents, + list(ws.plans.get_subjects(date)), session_counts, + context={"role": role_val, "objective": impact_val}, + ), escapable=True) + subject_val = subject.value if subject else None + + # Step 5: Mode (action in data model) + 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, existing_intents, + list(ws.plans.get_actions(date)), session_counts, + context={"role": role_val, "objective": impact_val, "subject": subject_val}, + ), escapable=True) + mode_val = mode.value if mode else None + + # Step 6: Trackers + trackers: list[str] = [] + 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, "objective": impact_val, "subject": subject_val, "action": mode_val} + while True: + if all(t in trackers for t in all_tracker_ids): + break + + weighted, has_correlated = get_weighted_tracker_choices( + alias, existing_intents, all_tracker_ids, tracker_names, + session_counts, 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 + + return match_or_create_intent(ws, alias, role_val, impact_val, mode_val, subject_val, trackers) - return intent_with_id @app.callback(invoke_without_command=True) def start( @@ -167,82 +263,37 @@ def start( ws: Workspace = ctx.obj date = ws.today() - # Determine start time if continue_from_last: - # Get the last session's end time log = ws.logs.get_log(date) if not log.timeline: typer.echo("No previous session found to continue from", err=True) raise typer.Exit(1) - last_session = log.timeline[-1] if last_session.end is None: typer.echo("Previous session is still active", err=True) raise typer.Exit(1) - start_time = last_session.end elif since: - # Parse the provided time (restricted to today) start_time = ws.parse_natural_datetime(since) else: - # Capture current time NOW, before any prompts start_time = ws.now() - existing_intents = ws.plans.get_intents(date) - - from rich.console import Console - console = Console() - console.print() - console.print("[bold]What are you doing?[/bold]") - console.print("[dim]What would you call this activity if you were naming an entry in your calendar?[/dim]") - console.print() + session_counts = defaultdict(int) + for log in ws.logs.list_logs(): + for session in log.timeline: + session_counts[session.intent.intent_id] += 1 - chosen_intent, _ = fuzzy_select( - prompt="Activity:", - choices=intents_to_choices(existing_intents), - escapable=False, - slugify_new=False, - ) + existing_intents = ws.plans.get_intents(date) - # If the intent is new, we'll want to prompt for details. - if not chosen_intent: + intent = _prompt_for_intent(ws, existing_intents, session_counts) + if not intent: typer.echo("aborting") return - if chosen_intent.is_new: - intent = input_new_intent(chosen_intent.value, ws) - else: - intent = chosen_intent.value + note = input("? Note for this session (optional): ") - # Rust core handles validation (future time, overlaps) and auto-stops active session ws.logs.start_intent(intent, start_time, note if note else None) typer.echo(f"Started '{intent.alias}' at {start_time.strftime('%H:%M')}") except Exception as e: typer.echo(f"Error starting session: {e}", err=True) raise typer.Exit(1) - -def intents_to_choices(intents): - """ - Convert intents to fuzzy select choices. - If multiple intents share the same alias, disambiguate by adding the intent_id. - """ - # Count alias occurrences to detect duplicates - alias_counts = {} - for intent in intents: - alias_counts[intent.alias] = alias_counts.get(intent.alias, 0) + 1 - - choices = [] - for intent in intents: - # If this alias appears more than once, disambiguate with intent_id - if alias_counts[intent.alias] > 1: - display_name = f"{intent.alias} ({intent.intent_id})" - else: - display_name = intent.alias - - choices.append({ - "name": display_name, - "value": intent, - "decoration": None - }) - - return choices