Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
@@ -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"}
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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": {}
}
7 changes: 4 additions & 3 deletions .trellis/workspace/pixelcola/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@

<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 7
- **Last Active**: 2026-05-12
- **Total Sessions**: 8
- **Last Active**: 2026-05-13
<!-- @@@/auto:current-status -->

---
Expand All @@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~238 | Active |
| `journal-1.md` | ~271 | Active |
<!-- @@@/auto:active-documents -->

---
Expand All @@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | 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` |
Expand Down
33 changes: 33 additions & 0 deletions .trellis/workspace/pixelcola/journal-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
33 changes: 25 additions & 8 deletions src/sing_cli/cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from datetime import datetime
from pathlib import Path

import typer
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
27 changes: 25 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import timedelta, timezone
from pathlib import Path

import pytest
Expand Down Expand Up @@ -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)

Expand Down