5 Commits

Author SHA1 Message Date
bcd52e0ae6 update project page url for package
Some checks failed
Build and Publish APK Package / build-apk (amd64, x86_64) (push) Successful in 42s
Build and Publish APK Package / build-apk (arm64, aarch64) (push) Successful in 43s
Build and Publish Arch Package / build-arch (amd64, x86_64) (push) Successful in 59s
Build and Publish Arch Package / build-arch (arm64, aarch64) (push) Successful in 59s
Build and Push Docker Container / build-and-push (push) Failing after 10m8s
2026-04-02 05:09:02 +02:00
4912f37688 remove claude markdown 2026-04-02 05:08:44 +02:00
f2521be158 feat: add due date filters for list command
- --due: show only nodes with a due date set
- --due-within N: show only nodes due within N days (includes overdue)

Implemented in service layer with post-fetch filtering, threaded through
API client and server, and exposed via CLI flags.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 05:06:35 +02:00
8357c80e75 feat: add due date to list sorting priority
Sort nodes by: status → priority → due date (soonest first).
Nodes without due dates sort last.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:57:51 +02:00
a30c703efa feat: add colored due date indicators to list and show views
- List view: show relative due date (+12d, -3d, today) after namespace
- Show view: colorize full due date based on urgency
- Color scheme: red (overdue), yellow (due within 3 days), green (4+ days)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-02 04:54:15 +02:00
9 changed files with 121 additions and 52 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
.ax
quicknote.md
plan.md
CLAUDE.md

View File

@@ -1,47 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Axolotl (`ax`) is a CLI-native issue tracker built in Go, using a local SQLite file (`.ax.db`) as its database. It's designed for use by individuals and AI agents, with JSON output support for machine integration.
## Commands
```bash
go build -o ax . # Build the binary
go test ./... # Run all tests (e2e_test.go covers most functionality)
go test -run TestName . # Run a single test by name
```
## Architecture
The codebase has three distinct layers:
### 1. `cmd/` — CLI layer (Cobra)
Parses flags into typed input structs and calls the service layer. `root.go` handles alias expansion (including `$me`, `$@`, `$1`-`$N` variable substitution) and wires up the `NodeService`. Also contains `output.go` for colored terminal output and JSON serialization.
### 2. `service/` — Business logic
`NodeService` is the central interface (`service/node_service.go`). The implementation (`node_service_impl.go`) enforces:
- Permission model via `getPermContext()` — BFS from the user's own node following permission rels
- Blocker validation (can't close an issue with open blockers)
- `@mention` extraction → automatic edge creation
- Single-value relation enforcement (`assignee`, `in_namespace`)
- Auto-creation of referenced user/namespace nodes
### 3. `store/` — Persistence and configuration
`GraphStore` interface wraps SQLite with graph primitives: nodes, tags, and typed directed edges. Schema is 3 tables (`nodes`, `tags`, `rels`). All multi-step ops use `store.Transaction()`. Also contains `Config` for user settings, aliases, and session management.
## Core Data Model
**Node**: a graph node with a 5-char ID, title, content, `Tags []string`, and `Relations map[string][]string`.
**Property tags** use the `_key::value` pattern: `_type::issue|note|user|namespace`, `_status::open|done`, `_prio::high|medium|low`.
**Relation types** (`models/rel_type.go`): `blocks`, `subtask`, `related`, `assignee` (single-value), `in_namespace` (single-value), `created`, `mentions`, `can_read`, `can_create_rel`, `can_write`, `has_ownership`.
**Permission model**: Four inclusive levels (14). Transitive via BFS from user's self-owned node. `can_read`=1, `can_create_rel`=2, `can_write`=3, `has_ownership`=4. Creator auto-gets `has_ownership` on every new node. Users self-own. Deleting a node cascades to all nodes it owns. User/namespace nodes are globally readable.
## Config
The CLI searches upward from CWD for an `.ax` directory (like git), falling back to `~/.config/ax/` for config and `~/.local/share/ax/` for data. The `AX_USER` env var overrides the configured username. The database file `ax.db` is similarly discovered by walking upward.

View File

@@ -4,7 +4,7 @@ pkgver=0.1.0
pkgrel=1
pkgdesc="CLI-native issue tracker using SQLite"
arch=('x86_64' 'aarch64')
url="https://g.eliaskohout.de/eliaskohout/axolotl"
url="https://g.eliaskohout.de/eliaskohout/ax"
license=('MIT')
makedepends=('go')
source=("$pkgname-$pkgver.tar.gz::https://g.eliaskohout.de/eliaskohout/axolotl/archive/v$pkgver.tar.gz")

View File

@@ -11,6 +11,8 @@ import (
var lTags, lRels []string
var lStatus, lPrio, lType, lNamespace, lAssignee, lMention string
var lDue bool
var lDueWithin int
var listCmd = &cobra.Command{
Use: "list", Short: "List nodes",
@@ -22,6 +24,11 @@ var listCmd = &cobra.Command{
}
var filter service.ListFilter
filter.HasDueDate = lDue
if lDueWithin >= 0 {
n := lDueWithin
filter.DueWithin = &n
}
// --tag is an alias for a label filter with no target.
for _, tag := range lTags {
@@ -76,4 +83,6 @@ func init() {
f.StringVar(&lNamespace, "namespace", "", "filter by namespace")
f.StringVar(&lAssignee, "assignee", "", "filter by assignee")
f.StringVar(&lMention, "mention", "", "filter by mention")
f.BoolVar(&lDue, "due", false, "filter to nodes with a due date")
f.IntVar(&lDueWithin, "due-within", -1, "filter to nodes due within N days (includes overdue)")
}

View File

@@ -7,8 +7,10 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strings"
"time"
"github.com/fatih/color"
)
@@ -74,7 +76,21 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
if si != sj {
return statusRanks[si] > statusRanks[sj]
}
return prioRanks[nodes[i].GetProperty("prio")] > prioRanks[nodes[j].GetProperty("prio")]
pi, pj := prioRanks[nodes[i].GetProperty("prio")], prioRanks[nodes[j].GetProperty("prio")]
if pi != pj {
return pi > pj
}
di, dj := nodes[i].DueDate, nodes[j].DueDate
if di == nil && dj == nil {
return false
}
if di == nil {
return false
}
if dj == nil {
return true
}
return di.Before(dj.Time) // soonest due date first
})
for _, n := range nodes {
@@ -89,13 +105,14 @@ func PrintNodes(w io.Writer, svc service.NodeService, nodes []*models.Node, json
}
ns_rel_node_titles = append(ns_rel_node_titles, ns_rel_node.Title)
}
fmt.Fprintf(w, " %s %s %s %s %s %s",
fmt.Fprintf(w, " %s %s %s %s %s %s %s",
cDim.Sprint(n.ID),
render(prioRM, n.GetProperty("prio"), true),
render(statusRM, n.GetProperty("status"), true),
render(typeRM, n.GetProperty("type"), true),
cTitle.Sprint(truncate(n.Title, 80)),
cDim.Sprint("["+strings.Join(ns_rel_node_titles, ",")+"]"),
dueDateShort(n.DueDate),
)
tags := n.GetDisplayTags()
if len(tags) > 0 {
@@ -117,7 +134,7 @@ func PrintNode(w io.Writer, svc service.NodeService, n *models.Node, jsonOut boo
fmt.Fprintf(w, " Status: %s\n", render(statusRM, n.GetProperty("status"), false))
fmt.Fprintf(w, " Priority: %s\n", render(prioRM, n.GetProperty("prio"), false))
if n.DueDate != nil {
fmt.Fprintf(w, " Due: %s %s\n", iconCalendar, n.DueDate.Format("2006-01-02"))
fmt.Fprintf(w, " Due: %s\n", dueDateLong(n.DueDate))
}
fmt.Fprintf(w, " Created: %s\n", cDim.Sprint(n.CreatedAt))
fmt.Fprintf(w, " Updated: %s\n", cDim.Sprint(n.UpdatedAt))
@@ -202,6 +219,57 @@ func render(rm RenderMap, key string, short bool) string {
return v.c.Sprint(v.l)
}
// dueDateShort returns a "+12d" / "-3d" style string colored by urgency.
func dueDateShort(d *models.Date) string {
if d == nil {
return ""
}
now := time.Now().UTC().Truncate(24 * time.Hour)
due := d.UTC().Truncate(24 * time.Hour)
days := int(math.Round(due.Sub(now).Hours() / 24))
var label string
if days == 0 {
label = "today"
} else if days > 0 {
label = fmt.Sprintf("+%dd", days)
} else {
label = fmt.Sprintf("%dd", days)
}
var c *color.Color
switch {
case days < 0:
c = cBad
case days <= 3:
c = cWarn
default:
c = cGood
}
return c.Sprint(label)
}
// dueDateLong returns the full date string colored by urgency.
func dueDateLong(d *models.Date) string {
if d == nil {
return ""
}
now := time.Now().UTC().Truncate(24 * time.Hour)
due := d.UTC().Truncate(24 * time.Hour)
days := int(math.Round(due.Sub(now).Hours() / 24))
var c *color.Color
switch {
case days < 0:
c = cBad
case days <= 3:
c = cWarn
default:
c = cGood
}
return c.Sprintf("%s %s", iconCalendar, d.Format("2006-01-02"))
}
func truncate(s string, max int) string {
if len(s) <= max {
return s

View File

@@ -6,6 +6,7 @@ import (
"axolotl/store"
"encoding/json"
"net/http"
"strconv"
"strings"
)
@@ -84,6 +85,14 @@ func (s *server) listNodes(w http.ResponseWriter, r *http.Request) {
if v := q.Get("mention"); v != "" {
filter.Rels = append(filter.Rels, service.RelInput{Type: models.RelMentions, Target: v})
}
if q.Get("has_due_date") == "true" {
filter.HasDueDate = true
}
if v := q.Get("due_within"); v != "" {
if n, err := strconv.Atoi(v); err == nil {
filter.DueWithin = &n
}
}
nodes, err := svc.List(filter)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"net/http"
"net/url"
"strconv"
)
type apiClient struct {
@@ -78,6 +79,12 @@ func (c *apiClient) List(filter ListFilter) ([]*models.Node, error) {
q.Add("rel", string(r.Type)+":"+r.Target)
}
}
if filter.HasDueDate {
q.Set("has_due_date", "true")
}
if filter.DueWithin != nil {
q.Set("due_within", strconv.Itoa(*filter.DueWithin))
}
path := "/nodes"
if len(q) > 0 {
path += "?" + q.Encode()

View File

@@ -57,6 +57,8 @@ type UpdateInput struct {
// Edge filters (Target != "") are resolved to node IDs.
type ListFilter struct {
Rels []RelInput
HasDueDate bool // when true, only return nodes that have a due date set
DueWithin *int // when non-nil, only return nodes due within this many days (includes overdue)
}
// RelInput is a typed, directed rel with a target that may be a name or node ID.

View File

@@ -234,6 +234,26 @@ func (s *nodeServiceImpl) List(filter ListFilter) ([]*models.Node, error) {
result = append(result, n)
}
}
if filter.HasDueDate || filter.DueWithin != nil {
now := time.Now().UTC().Truncate(24 * time.Hour)
filtered := result[:0]
for _, n := range result {
if n.DueDate == nil {
continue
}
if filter.DueWithin != nil {
due := n.DueDate.UTC().Truncate(24 * time.Hour)
cutoff := now.AddDate(0, 0, *filter.DueWithin)
if due.After(cutoff) {
continue
}
}
filtered = append(filtered, n)
}
result = filtered
}
return result, nil
}