Changes
11 changed files (+473/-2)
-
-
@@ -21,6 +21,9 @@# `zig build` inside `nix develop` is useful for development. zig-out # Zig fetches packages into this directory, since v0.16. zig-pkg # Nix build generates this too generic named symlink by default. /result
-
-
-
@@ -181,7 +181,6 @@{ name = system; value = { incus-import = let bin = pkgs.writeShellApplication {
-
@@ -203,6 +202,11 @@type = "app"; program = "${bin}/bin/incus-import"; }; zon2nix = { type = "app"; program = pkgs.lib.getExe pkgs.zon2nix; }; }; } ) availableSystems
-
@@ -246,6 +250,7 @@zig zls go gopls dprint nixfmt mkpasswd
-
-
-
@@ -13,7 +13,11 @@# # SPDX-License-Identifier: 0BSD { ... }: { pkgs, ... }: { imports = [ ./timetracker.nix ]; config = { home.packages = [ (pkgs.callPackage ../../../../programs/pomodoro { }) ]; }; }
-
-
-
@@ -0,0 +1,18 @@# Copyright 2026 Shota FUJI <pockawoooh@gmail.com> # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # Built executable. /pomodoro
-
-
-
@@ -0,0 +1,44 @@# Copyright 2026 Shota FUJI <pockawoooh@gmail.com> # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 { lib, buildGoModule }: buildGoModule rec { pname = "my-pomodoro"; version = "1.0.0"; src = with lib.fileset; toSource { root = ./.; fileset = unions [ ./go.mod ./go.sum ./main.go ./timer ./preferences ]; }; postInstall = '' mv $out/bin/pomodoro $out/bin/,pomodoro ''; vendorHash = "sha256-u2b5r2Almt+922gJegs7oZF3zafGYSS73aGnvHFCpZU="; meta = { mainProgram = ",pomodoro"; }; }
-
-
programs/pomodoro/go.mod (new)
-
@@ -0,0 +1,42 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 module pocka.jp/x/system/programs/pomodoro go 1.26.3 require ( charm.land/bubbletea/v2 v2.0.7 charm.land/lipgloss/v2 v2.0.3 github.com/charmbracelet/x/ansi v0.11.7 ) require ( github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect )
-
-
programs/pomodoro/go.sum (new)
-
@@ -0,0 +1,40 @@charm.land/bubbletea/v2 v2.0.7 h1:7qw2tTAVar7m7klOPBYfTB0mniv/RuexsYwMRNxSeL0= charm.land/bubbletea/v2 v2.0.7/go.mod h1:DGW2q8gvzHnOpMpZTORs0aySVHCox5C+2Svk0fci1qs= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654/go.mod h1:hFpumms29Smx3LStRfku8vcCTBe1Kq8aCXtHUJa3mjY= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
-
-
-
@@ -0,0 +1,15 @@Copyright 2026 Shota FUJI <pockawoooh@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. SPDX-License-Identifier: Apache-2.0
-
-
-
@@ -0,0 +1,162 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package main import ( "fmt" "math" "os" "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/x/ansi" "pocka.jp/x/system/programs/pomodoro/preferences" "pocka.jp/x/system/programs/pomodoro/timer" ) type model struct { phase timer.Phase remaining time.Duration isRunning bool preferences preferences.Preferences timestamp time.Time size tea.WindowSizeMsg } func (m model) Init() tea.Cmd { return nil } type Tick time.Time func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyPressMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "space", "enter", "p": m.isRunning = !m.isRunning if !m.isRunning { return m, nil } m.timestamp = time.Now() return m, tea.Tick(time.Second, func(t time.Time) tea.Msg { return Tick(t) }) case "s": m.phase = m.phase.Next(m.preferences) m.remaining = m.phase.Duration() return m, nil case "r": m.isRunning = false m.phase = timer.NewWorkPhase(m.preferences) m.remaining = m.phase.Duration() return m, nil } case tea.WindowSizeMsg: m.size = msg return m, nil case Tick: if !m.isRunning { return m, nil } cmds := make([]tea.Cmd, 1, 2) cmds[0] = tea.Tick(time.Second, func(t time.Time) tea.Msg { return Tick(t) }) m.remaining -= time.Time(msg).Sub(m.timestamp) m.timestamp = time.Time(msg) if m.remaining <= 0 { m.phase = m.phase.Next(m.preferences) m.remaining = m.phase.Duration() cmds = append(cmds, tea.Raw(ansi.URxvtExt("notify", m.phase.DisplayName(), m.phase.Duration().String()))) } return m, tea.Batch(cmds...) } return m, nil } func formatDuration(d time.Duration) string { secs := math.Ceil(d.Seconds()) mins := uint(math.Trunc(secs / 60)) return fmt.Sprintf("%02d:%02d", mins, uint(secs)%60) } func (m model) View() tea.View { usageStyle := lipgloss.NewStyle().Foreground(lipgloss.Black).Bold(true) usageSection := lipgloss.Wrap(usageStyle.Render("space/p: Toggle play/pause, s: Skip to next, r: Reset, q: Quit"), m.size.Width, ", ") titleStyle := lipgloss.NewStyle().Bold(true) remainingStyle := lipgloss.NewStyle() if m.isRunning { titleStyle = titleStyle.Foreground(lipgloss.Blue) if m.remaining <= time.Second*5 { remainingStyle = remainingStyle.Foreground(lipgloss.Red) } } title := titleStyle.Render(m.phase.DisplayName()) durations := remainingStyle.Render(formatDuration(m.remaining)) + " / " + formatDuration(m.phase.Duration()) mainSection := lipgloss.Place( m.size.Width, m.size.Height-lipgloss.Height(usageSection), lipgloss.Center, lipgloss.Center, lipgloss.JoinVertical(lipgloss.Top, title, durations), ) v := tea.NewView(lipgloss.JoinVertical(lipgloss.Center, mainSection, usageSection)) v.AltScreen = true return v } func newModel(pref preferences.Preferences) model { phase := timer.NewWorkPhase(pref) return model{ phase: phase, remaining: phase.Duration(), isRunning: false, preferences: pref, timestamp: time.Now(), } } func main() { p := tea.NewProgram(newModel(preferences.Preferences{ SessionCount: 4, WorkDuration: time.Minute * 25, ShortBreakDuration: time.Minute * 5, LongBreakDuration: time.Minute * 30, })) if _, err := p.Run(); err != nil { fmt.Print(err) os.Exit(1) } }
-
-
-
@@ -0,0 +1,30 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package preferences import ( "time" ) type Preferences struct { // SessionCount describes how many work phase to run before long break. SessionCount uint WorkDuration time.Duration ShortBreakDuration time.Duration LongBreakDuration time.Duration }
-
-
-
@@ -0,0 +1,108 @@// Copyright 2026 Shota FUJI <pockawoooh@gmail.com> // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 package timer import ( "fmt" "time" "pocka.jp/x/system/programs/pomodoro/preferences" ) type Phase interface { // DisplayName returns user friendly display name of the phase. DisplayName() string // Next returns a phase to transition after completing or skipping this phase. Next(preferences.Preferences) Phase Duration() time.Duration } type WorkPhase struct { Round uint duration time.Duration } func (p WorkPhase) DisplayName() string { return fmt.Sprintf("Work (#%d)", p.Round) } func (p WorkPhase) Next(pref preferences.Preferences) Phase { if p.Round >= pref.SessionCount { return LongBreakPhase{ duration: pref.LongBreakDuration, } } else { return ShortBreakPhase{ Round: p.Round, duration: pref.ShortBreakDuration, } } } func (p WorkPhase) Duration() time.Duration { return p.duration } func NewWorkPhase(pref preferences.Preferences) WorkPhase { return WorkPhase{ Round: 1, duration: pref.WorkDuration, } } type ShortBreakPhase struct { Round uint duration time.Duration } func (p ShortBreakPhase) DisplayName() string { return fmt.Sprintf("Short Break (#%d)", p.Round) } func (p ShortBreakPhase) Next(pref preferences.Preferences) Phase { return WorkPhase{ Round: p.Round + 1, duration: pref.WorkDuration, } } func (p ShortBreakPhase) Duration() time.Duration { return p.duration } type LongBreakPhase struct { duration time.Duration } func (p LongBreakPhase) DisplayName() string { return "Long Break" } func (p LongBreakPhase) Next(pref preferences.Preferences) Phase { return WorkPhase{ Round: 1, duration: pref.WorkDuration, } } func (p LongBreakPhase) Duration() time.Duration { return p.duration }
-