diff --git a/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/check.jsonl b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/check.jsonl new file mode 100644 index 0000000..c430b13 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/check.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend command contracts and checklist"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Python quality commands and tests"} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "CLI stdout and list output constraints"} diff --git a/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/implement.jsonl b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/implement.jsonl new file mode 100644 index 0000000..9aabb6d --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/implement.jsonl @@ -0,0 +1,3 @@ +{"file": ".trellis/spec/backend/index.md", "reason": "Backend command contracts and checklist"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "Python quality commands and CLI contract"} +{"file": ".trellis/spec/backend/logging-guidelines.md", "reason": "CLI stdout and list output constraints"} diff --git a/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/prd.md b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/prd.md new file mode 100644 index 0000000..661e5b0 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/prd.md @@ -0,0 +1,37 @@ +# Use local timezone in sing list + +## Goal + +`sing list` prints each profile update time in the computer's local timezone, while preserving the existing UTC storage format in `state.json`. + +## What I already know + +- `src/sing_cli/state.py` stores `updated_at` with `now_utc()` as an ISO UTC string ending in `Z`. +- `src/sing_cli/cli.py` prints `entry.updated_at` directly in `list_profiles()`. +- Existing list output tests live in `tests/test_cli.py`. +- The task branch is `fix/list-local-timezone`. + +## Requirements + +- Keep `add` and `update` writing UTC timestamps to state. +- Convert stored profile timestamps to the computer's local timezone when `sing list` prints them. +- Preserve the existing list columns and active-profile marker. +- Surface invalid stored timestamps as explicit CLI errors. + +## Acceptance Criteria + +- [x] `sing list` converts a UTC `Z` timestamp to the local timezone before printing. +- [x] Existing state persistence tests still pass without format migration. +- [x] CLI tests cover local-timezone rendering. +- [x] Ruff, ty, and pytest pass for the affected project. + +## Out of Scope + +- Changing the state file timestamp format. +- Adding user-configurable timezone flags. +- Changing the command table layout. + +## Technical Notes + +- Relevant specs: `.trellis/spec/backend/index.md`, `.trellis/spec/backend/quality-guidelines.md`, `.trellis/spec/backend/logging-guidelines.md`. +- Relevant files: `src/sing_cli/cli.py`, `src/sing_cli/state.py`, `tests/test_cli.py`. diff --git a/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/task.json b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/task.json new file mode 100644 index 0000000..284df31 --- /dev/null +++ b/.trellis/tasks/archive/2026-05/05-12-use-local-timezone-in-sing-list/task.json @@ -0,0 +1,26 @@ +{ + "id": "use-local-timezone-in-sing-list", + "name": "use-local-timezone-in-sing-list", + "title": "Use local timezone in sing list", + "description": "", + "status": "completed", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "pixelcola", + "assignee": "pixelcola", + "createdAt": "2026-05-12", + "completedAt": "2026-05-13", + "branch": "fix/list-local-timezone", + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/workspace/pixelcola/index.md b/.trellis/workspace/pixelcola/index.md index bf8e510..64517c7 100644 --- a/.trellis/workspace/pixelcola/index.md +++ b/.trellis/workspace/pixelcola/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 7 -- **Last Active**: 2026-05-12 +- **Total Sessions**: 8 +- **Last Active**: 2026-05-13 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~238 | Active | +| `journal-1.md` | ~271 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | Branch | |---|------|-------|---------|--------| +| 8 | 2026-05-13 | Use local timezone in sing list | `c6a44bf` | `fix/list-local-timezone` | | 7 | 2026-05-12 | Expand test coverage | `764205a` | `test/improve-coverage` | | 6 | 2026-05-12 | Update README installation instructions | `49680f6` | `docs/readme-install-uv-tool` | | 5 | 2026-05-12 | Fix restart active profile | `010e59e` | `fix/restart-active-config` | diff --git a/.trellis/workspace/pixelcola/journal-1.md b/.trellis/workspace/pixelcola/journal-1.md index abe7693..c517420 100644 --- a/.trellis/workspace/pixelcola/journal-1.md +++ b/.trellis/workspace/pixelcola/journal-1.md @@ -236,3 +236,36 @@ Added CLI, profile, state, and service tests covering command success paths, fai ### Next Steps - None - task complete + + +## Session 8: Use local timezone in sing list + +**Date**: 2026-05-13 +**Task**: Use local timezone in sing list +**Branch**: `fix/list-local-timezone` + +### Summary + +Changed sing list to render stored UTC profile update timestamps in the computer's local timezone, preserved UTC state storage, added CLI tests for local rendering and invalid timestamps, committed the fix, and opened PR #9. + +### Main Changes + +(Add details) + +### Git Commits + +| Hash | Message | +|------|---------| +| `c6a44bf` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/src/sing_cli/cli.py b/src/sing_cli/cli.py index abcfe6c..68b8d60 100644 --- a/src/sing_cli/cli.py +++ b/src/sing_cli/cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from pathlib import Path import typer @@ -61,6 +62,22 @@ def fail(error: SingCliError) -> None: raise typer.Exit(1) +def to_local_timezone(value: datetime) -> datetime: + return value.astimezone() + + +def format_local_updated_at(profile_name: str, updated_at: str) -> str: + if not updated_at.endswith("Z"): + raise SingCliError(f"Profile '{profile_name}' has invalid updated_at timestamp: {updated_at}") + + try: + updated_at_utc = datetime.fromisoformat(updated_at.removesuffix("Z") + "+00:00") + except ValueError as error: + raise SingCliError(f"Profile '{profile_name}' has invalid updated_at timestamp: {updated_at}") from error + + return to_local_timezone(updated_at_utc).isoformat() + + @app.command() def install(bin: Path | None = typer.Option(None, "--bin", help="Path to sing-box.exe.")) -> None: try: @@ -173,13 +190,13 @@ def update(name: str) -> None: def list_profiles() -> None: try: state = load_cli_state() - except SingCliError as error: - fail(error) - if not state.profiles: - typer.echo("No profiles") - return + if not state.profiles: + typer.echo("No profiles") + return - for name, entry in sorted(state.profiles.items()): - marker = "*" if state.active == name else " " - typer.echo(f"{marker} {name}\t{entry.url}\t{entry.updated_at}") + for name, entry in sorted(state.profiles.items()): + marker = "*" if state.active == name else " " + typer.echo(f"{marker} {name}\t{entry.url}\t{format_local_updated_at(name, entry.updated_at)}") + except SingCliError as error: + fail(error) diff --git a/tests/test_cli.py b/tests/test_cli.py index c1f937a..6315bf9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +from datetime import timedelta, timezone from pathlib import Path import pytest @@ -541,16 +542,38 @@ def test_list_profiles_marks_active_profile(tmp_path: Path, monkeypatch: pytest. ), ) monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + monkeypatch.setattr(cli, "to_local_timezone", lambda value: value.astimezone(timezone(timedelta(hours=8)))) result = CliRunner().invoke(cli.app, ["list"]) assert result.exit_code == 0 assert result.stdout == ( - " home\thttps://example.com/home.json\t2026-05-12T00:00:00Z\n" - "* work\thttps://example.com/work.json\t2026-05-12T01:00:00Z\n" + " home\thttps://example.com/home.json\t2026-05-12T08:00:00+08:00\n" + "* work\thttps://example.com/work.json\t2026-05-12T09:00:00+08:00\n" ) +def test_list_profiles_reports_invalid_updated_at(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + write_state( + tmp_path, + State( + profiles={ + "home": ProfileEntry( + url="https://example.com/home.json", + path="C:/profiles/home", + updated_at="2026-05-12T00:00:00", + ) + }, + ), + ) + monkeypatch.setattr(cli, "app_dir", lambda: tmp_path) + + result = CliRunner().invoke(cli.app, ["list"]) + + assert result.exit_code == 1 + assert result.stderr == "Error: Profile 'home' has invalid updated_at timestamp: 2026-05-12T00:00:00\n" + + def test_list_profiles_reports_empty_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli, "app_dir", lambda: tmp_path)