diff --git a/src/faff_cli/config.py b/src/faff_cli/config.py new file mode 100644 index 0000000..35c5dfb --- /dev/null +++ b/src/faff_cli/config.py @@ -0,0 +1,71 @@ +""" +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 + 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.""" + 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), + max_trackers=session_data.get("max_trackers", 0), + ) + + return CliConfig(session=session) diff --git a/src/faff_cli/field.py b/src/faff_cli/field.py index b35db70..0f42bfb 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": @@ -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"), ] @@ -182,7 +184,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 +193,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) @@ -207,10 +208,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..7da689c 100644 --- a/src/faff_cli/main.py +++ b/src/faff_cli/main.py @@ -1,6 +1,7 @@ import typer +from typing import Optional -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, timeline, __version__ from faff_cli.utils import edit_file import faff_core @@ -13,14 +14,15 @@ 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") +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") -# 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") @@ -192,7 +194,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) @@ -237,7 +239,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 @@ -335,9 +337,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] @@ -353,7 +359,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. @@ -365,6 +374,7 @@ def status(ctx: typer.Context): Examples: faff status + faff status --show-stale """ from rich.console import Console @@ -394,8 +404,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]") @@ -415,13 +424,15 @@ def status(ctx: typer.Context): 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.intent.alias} [dim]({active_session.note})[/dim] · {duration_minutes}m") - else: - console.print(f" [green]●[/green] {active_session.intent.alias} · {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]") @@ -452,7 +463,7 @@ def status(ctx: typer.Context): 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)) @@ -469,11 +480,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) @@ -492,8 +506,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} @@ -505,7 +523,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 @@ -514,7 +532,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] + "..." @@ -524,11 +542,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]") @@ -540,45 +561,112 @@ def status(ctx: typer.Context): 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): +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_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 - intent_alias = active.intent.alias - start_time = active.start - - # Stop the session - ws.logs.stop_current_session() - - # Calculate duration end_time = ws.now() - duration = end_time - start_time - duration_minutes = int(duration.total_seconds() / 60) - - # Show feedback - typer.echo(f"Stopped '{intent_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") + + 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/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..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" Intent: {session.intent.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 7401fa8..f800ee8 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 """ @@ -112,14 +112,15 @@ 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", - "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 "", + "title": session.title, + "role": session.role 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"), "duration_seconds": duration.total_seconds(), "reflection": f"{session.reflection_score:.1f}" if session.reflection_score is not None else "", @@ -143,9 +144,10 @@ def list_sessions( # Define columns for table output columns = [ ("date", "Date", "cyan"), + ("id", "ID", "dim"), ("start", "Start", None), ("end", "End", None), - ("alias", "Intent", "yellow"), + ("title", "Title", "yellow"), ("duration", "Duration", "green"), ("reflection", "Reflection", "blue"), ] @@ -179,7 +181,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 +222,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 248340d..a4a6442 100644 --- a/src/faff_cli/sql.py +++ b/src/faff_cli/sql.py @@ -27,11 +27,10 @@ def load_ledger_to_db(ws, db_path: Path): cursor.execute(''' CREATE TABLE sessions ( date TEXT, - intent_id TEXT, - alias TEXT, + title TEXT, role TEXT, - objective TEXT, - action TEXT, + impact TEXT, + mode TEXT, subject TEXT, start TEXT, end 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.title, + session.role, + session.impact, + session.mode, + 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_title ON sessions(title)') conn.commit() conn.close() diff --git a/src/faff_cli/start.py b/src/faff_cli/start.py index 7a170ee..bf74b1b 100644 --- a/src/faff_cli/start.py +++ b/src/faff_cli/start.py @@ -1,12 +1,13 @@ import typer from collections import defaultdict +from types import SimpleNamespace from titlecase import titlecase from faff_cli.ui import FuzzyItem, fuzzy_select +from faff_cli.config import load_config from faff_core import Workspace -from faff_core.models import Intent app = typer.Typer(help="Start a new task or activity.") @@ -33,56 +34,95 @@ 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, 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 = [ - 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 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(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_title_choices(all_sessions): + """Build title choices from historical sessions, sorted by frequency. - sorted_aliases = sorted(alias_totals.items(), key=lambda x: x[1], reverse=True) + 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 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 + + # 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 alias, count in sorted_aliases: - decoration = f"({count})" if count > 0 else None - choices.append(FuzzyItem(name=alias, value=alias, 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_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) +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. + 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) - for intent in matching: - val = getattr(intent, field_name, None) - if val: - field_freq[val] += session_counts_by_id.get(intent.intent_id, 0) + hint_only_vals = set() + for session in matching: + val = getattr(session, field_name, None) + 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} - 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 if not getattr(s, '_is_hint', False)} + all_historical.discard(None) + 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: @@ -92,18 +132,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,129 +167,210 @@ 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) +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 + ] - # Get the intent back with its generated ID - return [i for i in new_plan.intents if i.alias == alias][-1] +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_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 session fields per CLI config. Returns field dict, or None if aborted.""" from rich.console import Console console = Console() date = ws.today() - - # Step 1: Alias - 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( - 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 + 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 + 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, "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, + 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(sessions_with_hints), + 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, sessions_with_hints, + plan_data["roles"], + ), 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, sessions_with_hints, + plan_data["subjects"], + 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, sessions_with_hints, + plan_data["impacts"], + 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, 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 = 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} + + auto_matches = _resolve_tracker( + all_hints, role_val, subject_val, impact_val, mode_val, + exclude=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) + # 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]") + + 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 = [] 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, + ) + 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 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 not tracker_limit_reached(): + 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 not tracker_limit_reached(): + if all(t in trackers for t in all_tracker_ids): + break + + weighted, has_correlated = get_weighted_tracker_choices( + title, sessions_with_hints, 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 @app.callback(invoke_without_command=True) @@ -257,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: @@ -278,22 +400,38 @@ def start( else: start_time = ws.now() - session_counts = defaultdict(int) - for log in ws.logs.list_logs(): - for session in log.timeline: - session_counts[session.intent.intent_id] += 1 + # Gather recent historical sessions for weighted choices (last 90 days is plenty) + all_sessions = [] + for log in ws.logs.list_logs_recent(90): + all_sessions.extend(log.timeline) - existing_intents = ws.plans.get_intents(date) + title, role, impact, mode, subject, trackers = _prompt_for_session_fields(ws, all_sessions) - intent = _prompt_for_intent(ws, existing_intents, session_counts) - if not intent: - typer.echo("aborting") - return + cfg = load_config().session + note = input("? Note for this session (optional): ") if cfg.note else None - 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')}") + # For sequential start (default), stop any active sessions first + if not concurrent: + log = ws.logs.get_log(date) + if log.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, + role=role, + impact=impact, + mode=mode, + subject=subject, + trackers=trackers, + start_time=start_time, + note=note if note else None, + ) + 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) diff --git a/src/faff_cli/timeline.py b/src/faff_cli/timeline.py new file mode 100644 index 0000000..90d8ed7 --- /dev/null +++ b/src/faff_cli/timeline.py @@ -0,0 +1,366 @@ +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 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)) + + 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 + # 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 + 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_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 + 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 = time_fmt + + 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 + # 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} {be_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} {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] + 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 = time_fmt + + # 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]") diff --git a/src/faff_cli/ui/fuzzy_select.py b/src/faff_cli/ui/fuzzy_select.py index 554edf3..26a9bfb 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,10 +36,11 @@ 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) -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, @@ -63,7 +48,21 @@ def fuzzy_select(prompt: str, 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)) @@ -141,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) @@ -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, @@ -227,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 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: