feat(habit): implement track command

Amolith created

Uses go-dateparser for natural language date input (e.g., "yesterday",
"2 days ago", "March 5"). Adds internal/dateutil package for reuse.

Assisted-by: Claude Sonnet 4 via Crush

Change summary

AGENTS.md                     |  2 +
cmd/habit/track.go            | 65 ++++++++++++++++++++++++++++++------
go.mod                        |  7 +++
go.sum                        | 14 +++++++
internal/dateutil/dateutil.go | 29 ++++++++++++++++
5 files changed, 105 insertions(+), 12 deletions(-)

Detailed changes

AGENTS.md 🔗

@@ -94,6 +94,8 @@ Reference docs via context7 or doc-agent when unsure:
   parsing.
 - `github.com/spf13/cobra` (context7:
   `/websites/pkg_go_dev_github_com_spf13_cobra`): CLI framework.
+- `github.com/markusmobius/go-dateparser` (context7:
+  `/markusmobius/go-dateparser`): Natural language date parsing.
 
 ## Gotchas
 

cmd/habit/track.go 🔗

@@ -5,12 +5,21 @@
 package habit
 
 import (
+	"errors"
 	"fmt"
 
+	"git.secluded.site/go-lunatask"
+	"git.secluded.site/lune/internal/client"
 	"git.secluded.site/lune/internal/completion"
+	"git.secluded.site/lune/internal/config"
+	"git.secluded.site/lune/internal/dateutil"
+	"git.secluded.site/lune/internal/ui"
 	"github.com/spf13/cobra"
 )
 
+// ErrUnknownHabit indicates the specified habit key was not found in config.
+var ErrUnknownHabit = errors.New("unknown habit key")
+
 // TrackCmd tracks a habit activity. Exported for potential use by shortcuts.
 var TrackCmd = &cobra.Command{
 	Use:   "track KEY",
@@ -18,22 +27,54 @@ var TrackCmd = &cobra.Command{
 	Long: `Record that a habit was performed.
 
 KEY is the habit key from your config (not the raw Lunatask ID).
-Tracks for today by default. Use --date to specify another date.`,
+Tracks for today by default. Use --date to specify another date
+using natural language (e.g., "yesterday", "2 days ago", "March 5").`,
 	Args:              cobra.ExactArgs(1),
 	ValidArgsFunction: completion.Habits,
-	RunE: func(cmd *cobra.Command, args []string) error {
-		date, _ := cmd.Flags().GetString("date")
-		if date == "" {
-			date = "today"
-		}
+	RunE:              runTrack,
+}
+
+func init() {
+	TrackCmd.Flags().StringP("date", "d", "", "Date performed (default: today)")
+}
+
+func runTrack(cmd *cobra.Command, args []string) error {
+	habitKey := args[0]
+
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
 
-		// TODO: implement habit tracking
-		fmt.Fprintf(cmd.OutOrStdout(), "Tracking habit %s for %s (not yet implemented)\n", args[0], date)
+	habit := cfg.HabitByKey(habitKey)
+	if habit == nil {
+		return fmt.Errorf("%w: %s", ErrUnknownHabit, habitKey)
+	}
 
-		return nil
-	},
+	date, err := resolveDate(cmd)
+	if err != nil {
+		return err
+	}
+
+	apiClient, err := client.New()
+	if err != nil {
+		return err
+	}
+
+	req := &lunatask.TrackHabitActivityRequest{PerformedOn: date}
+
+	if _, err := apiClient.TrackHabitActivity(cmd.Context(), habit.ID, req); err != nil {
+		return err
+	}
+
+	fmt.Fprintf(cmd.OutOrStdout(), "%s\n",
+		ui.Success.Render("Tracked "+habit.Name+" for "+date.String()))
+
+	return nil
 }
 
-func init() {
-	TrackCmd.Flags().StringP("date", "d", "", "Date performed (natural language, default: today)")
+func resolveDate(cmd *cobra.Command) (lunatask.Date, error) {
+	dateStr, _ := cmd.Flags().GetString("date")
+
+	return dateutil.Parse(dateStr)
 }

go.mod 🔗

@@ -39,8 +39,13 @@ require (
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/godbus/dbus/v5 v5.1.0 // indirect
+	github.com/hablullah/go-hijri v1.0.2 // indirect
+	github.com/hablullah/go-juliandays v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 // indirect
 	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
+	github.com/magefile/mage v1.14.0 // indirect
+	github.com/markusmobius/go-dateparser v1.2.4 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
 	github.com/mattn/go-runewidth v0.0.19 // indirect
@@ -54,6 +59,8 @@ require (
 	github.com/muesli/termenv v0.16.0 // indirect
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/spf13/pflag v1.0.10 // indirect
+	github.com/tetratelabs/wazero v1.2.1 // indirect
+	github.com/wasilibs/go-re2 v1.3.0 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 	golang.org/x/sync v0.19.0 // indirect
 	golang.org/x/sys v0.39.0 // indirect

go.sum 🔗

@@ -77,12 +77,22 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnmp6k=
+github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
+github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
+github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958 h1:qxLoi6CAcXVzjfvu+KXIXJOAsQB62LXjsfbOaErsVzE=
+github.com/jalaali/go-jalaali v0.0.0-20210801064154-80525e88d958/go.mod h1:Wqfu7mjUHj9WDzSSPI5KfBclTTEnLveRUFr/ujWnTgE=
 github.com/klauspost/lctime v0.1.0 h1:nINsuFc860M9cyYhT6vfg6U1USh7kiVBj/s/2b04U70=
 github.com/klauspost/lctime v0.1.0/go.mod h1:OwdMhr8tbQvusAsnilqkkgDQqivWlqyg0w5cfXkLiDk=
 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
 github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/magefile/mage v1.14.0 h1:6QDX3g6z1YvJ4olPhT1wksUcSa/V0a1B+pJb73fBjyo=
+github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
+github.com/markusmobius/go-dateparser v1.2.4 h1:2e8XJozaERVxGwsRg72coi51L2aiYqE2gukkdLc85ck=
+github.com/markusmobius/go-dateparser v1.2.4/go.mod h1:CBAUADJuMNhJpyM6IYaWAoFhtKaqnUcznY2cL7gNugY=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
@@ -119,6 +129,10 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
 github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/tetratelabs/wazero v1.2.1 h1:J4X2hrGzJvt+wqltuvcSjHQ7ujQxA9gb6PeMs4qlUWs=
+github.com/tetratelabs/wazero v1.2.1/go.mod h1:wYx2gNRg8/WihJfSDxA1TIL8H+GkfLYm+bIfbblu9VQ=
+github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw=
+github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s=

internal/dateutil/dateutil.go 🔗

@@ -0,0 +1,29 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+// Package dateutil provides natural language date parsing for lune commands.
+package dateutil
+
+import (
+	"fmt"
+
+	"git.secluded.site/go-lunatask"
+	dps "github.com/markusmobius/go-dateparser"
+)
+
+// Parse parses a natural language date string into a lunatask.Date.
+// Supports formats like "yesterday", "2 days ago", "March 5", "2024-01-15", etc.
+// Returns today's date if the input is empty.
+func Parse(input string) (lunatask.Date, error) {
+	if input == "" {
+		return lunatask.Today(), nil
+	}
+
+	parsed, err := dps.Parse(nil, input)
+	if err != nil {
+		return lunatask.Date{}, fmt.Errorf("parsing date %q: %w", input, err)
+	}
+
+	return lunatask.NewDate(parsed.Time), nil
+}