Archived
1
0
Fork 0
This repository has been archived on 2024-10-19. You can view files and clone it, but cannot push or open issues or pull requests.
emacs/org/elpa/pomidor-20240601.1617/pomidor.el
2024-08-14 12:18:36 -04:00

716 lines
25 KiB
EmacsLisp

;;; pomidor.el --- Simple and cool pomodoro timer
;; Author: TatriX <tatrics@gmail.com>
;; URL: https://github.com/TatriX/pomidor
;; Keywords: tools, time, applications, pomodoro technique
;; Version: 0.6.2
;; Package-Requires: ((emacs "24.3") (alert "1.2") (dash "2.17.0"))
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation; either version 2, or (at your option)
;; any later version.
;;
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
;; GNU General Public License for more details.
;;
;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; pomidor is a simple and cool
;; [[http://www.pomodorotechnique.com/][pomodoro technique]] timer.
;;; Code:
(require 'cl-lib)
(require 'alert)
(require 'dash)
(require 'json)
(require 'parse-time)
;;; Customs
(defgroup pomidor nil
"Customs for `pomidor'"
:group 'productivity)
(defcustom pomidor-buffer-name "*pomidor*"
"Name of the pomidor buffer."
:type 'string :group 'pomidor)
(defcustom pomidor-history-buffer-name "*pomidor-history*"
"Name of the pomidor history buffer."
:type 'string :group 'pomidor)
(defcustom pomidor-seconds (* 25 60)
"Time length of a Podomoro round."
:type 'integer :group 'pomidor)
(defcustom pomidor-break-seconds (* 5 60)
"Time length of a Podomoro break."
:type 'integer :group 'pomidor)
(defcustom pomidor-update-interval 60
"Interval in seconds when pomidor should run hooks and play overwork sounds."
:type 'integer :group 'pomidor)
(defcustom pomidor-confirm-end-break t
"If t ask for confirmation before ending a break and starting new a pomodoro."
:type 'boolean :group 'pomidor)
;;; Vars
(defcustom pomidor-time-format "%H:%M:%S"
"Time format for podomoro clock."
:type 'string :group 'pomidor)
(defcustom pomidor-history-date-format "%Y-%m-%dT%H:%M:%S"
"Date format used to save the session ended value."
:type 'string :group 'pomidor)
(defcustom pomidor-duration-format "%H:%M:%S"
"Time format for duration intervals."
:type 'string :group 'pomidor)
(defconst pomidor-dir (file-name-directory (or load-file-name buffer-file-name))
"Pomidor directory in which sounds store.")
(defcustom pomidor-sound-tick (expand-file-name (concat pomidor-dir "tick.wav"))
"Tick sound during a pomoro run."
:type '(file :must-match t)
:group 'pomidor)
(defcustom pomidor-sound-tack (expand-file-name (concat pomidor-dir "tack.wav"))
"Tick sound during a pomoro run."
:type '(file :must-match t)
:group 'pomidor)
(defcustom pomidor-sound-overwork (expand-file-name (concat pomidor-dir "overwork.wav"))
"Overwork sound."
:type '(file :must-match t)
:group 'pomidor)
(defcustom pomidor-sound-break-over (expand-file-name (concat pomidor-dir "overwork.wav"))
"Break over sound."
:type '(file :must-match t)
:group 'pomidor)
(defcustom pomidor-save-session-file (expand-file-name "pomidor-session.json" user-emacs-directory)
"Pomidor session store file."
:type '(file :must-match t)
:group 'pomidor)
(defcustom pomidor-breaks-before-long 4
"How many short breaks before the long break."
:type 'integer :group 'pomidor)
(defcustom pomidor-long-break-seconds (* 20 60)
"Time length of a Podomoro long break."
:type 'integer :group 'pomidor)
;; libnotify for some reason can't display svg
(defvar pomidor-icon (concat data-directory "images/icons/hicolor/16x16/apps/emacs.png")
"Default pomidor icon.")
(defun pomidor-default-alert-message ()
"Default pomidor alert message if any."
(cond
((and (pomidor-overwork-p) (pomidor-should-long-break-p))
(format "Take a long break!\nOverwork: [%s]"
(format-time-string "%H:%M:%S" (pomidor-overwork-duration) t)))
((pomidor-overwork-p)
(format "Take a break!\nOverwork: [%s]"
(format-time-string "%H:%M:%S" (pomidor-overwork-duration) t)))
((pomidor-break-over-notify-p)
(format "Go back to work!\nBreak: [%s]"
(format-time-string "%H:%M:%S" (pomidor-break-duration) t)))))
(defun pomidor-default-alert ()
"Default pomidor alert."
(let ((message (pomidor-default-alert-message)))
(when message
(alert message
:severity 'normal
:icon pomidor-icon
:title "Pomidor"
:category 'pomidor))))
(defcustom pomidor-alert #'pomidor-default-alert
"Pomidor alert function.
To disable alerts, set to nil."
:type '(choice (const nil)
function)
:group 'pomidor)
(defun pomidor-play-sound-file-emacs (file)
"Play FILE by starting new Emacs process."
(if (fboundp 'play-sound-internal)
(start-process "pomidor-play-sound-file-emacs"
nil
(car command-line-args)
"-Q"
"--batch"
"--eval" (format "(play-sound-file \"%s\")" file))
(warn "This Emacs binary lacks sound support")))
(defcustom pomidor-play-sound-file #'pomidor-play-sound-file-emacs
"Function to play sounds (preferably async).
To disable sounds, set to nil."
:type '(choice (const nil)
function)
:group 'pomidor)
(defvar pomidor-update-hook nil)
;;; Faces
(defface pomidor-time-face
'((t (:height 4.0)))
"pomidor face for time"
:group 'pomidor)
(defface pomidor-date-face
'((t (:height 4.0)))
"pomidor face for date"
:group 'pomidor)
(defface pomidor-timer-face
'((t (:height 5.0)))
"pomidor face for timer"
:group 'pomidor)
(defface pomidor-work-face
'((t (:inherit success)))
"pomidor face for work"
:group 'pomidor)
(defface pomidor-overwork-face
'((t (:inherit warning)))
"pomidor face for overwork"
:group 'pomidor)
(defface pomidor-break-face
'((t (:inherit font-lock-keyword-face)))
"pomidor face for break"
:group 'pomidor)
(defface pomidor-skip-face
'((t (:inherit font-lock-comment-face)))
"pomidor face for skip"
:group 'pomidor)
;;; Vars
(defvar pomidor-timer nil
"Pomidor timer.")
(defvar pomidor-global-state nil
"Pomidor global state.")
(defvar pomidor-graph-char ?█
"Pomidor char for displaying tubes.")
(defvar pomidor-header-separator ""
"Pomidor string to separate time and duration in header.")
(defvar pomidor-header-session-name-separator " : "
"Pomidor string to separate the regular header from the session name in history mode.")
(defvar pomidor--current-history-session nil
"Hold the current visible pomidor history snapshot.")
(defvar pomidor--count-short-breaks 0
"Pomidor integer of how many short breaks we have before a long break.")
(defvar pomidor--system-on-hold-p nil
"Pomidor control of hold in system.")
;;; Private
(defun pomidor--current-state ()
"Return current state."
(car (last pomidor-global-state)))
(defun pomidor--reset ()
"Delete current global state."
(setq pomidor-global-state (list (pomidor--make-state))))
(defun pomidor--make-state ()
"Make pomidor state."
(list :started (current-time)
:break nil
:stopped nil
:snooze nil))
(defun pomidor--started (state)
"Return started time for STATE."
(plist-get state :started))
(defun pomidor--break (state)
"Return break time for STATE."
(plist-get state :break))
(defun pomidor--stopped (state)
"Return stopped time for STATE.
It's a time when user started a new timer after this one."
(plist-get state :stopped))
(defun pomidor--ended (state)
"Return ended time for STATE.
It's either stopped time or current time."
(or (pomidor--stopped state) (current-time)))
(defun pomidor--work-duration (state)
"Return work time for STATE."
(let* ((started (pomidor--started state))
(ended (or (pomidor--break state) (pomidor--ended state)))
(work (time-subtract ended started))
(max (seconds-to-time pomidor-seconds)))
(if (time-less-p work max)
work
max)))
(defun pomidor--overwork-duration (state)
"Return overwork time for STATE or nil."
;; (cur - started) - (cur - break) - max
(let* ((started (pomidor--started state))
(break (or (pomidor--break state) (pomidor--ended state)))
(work (pomidor--work-duration state))
(ended (pomidor--ended state))
(max (seconds-to-time pomidor-seconds))
(overwork (time-subtract (time-subtract (time-subtract ended started)
(time-subtract ended break))
max)))
(when (> (time-to-seconds overwork) 0)
overwork)))
(defun pomidor-running-p ()
"Return t if pomidor is running right now."
(timerp pomidor-timer))
(defun pomidor-overwork-p ()
"Return t if current state is overwork."
(let* ((state (pomidor--current-state))
(overwork (pomidor--overwork-duration state)))
(and overwork (null (pomidor--break state)))))
(defun pomidor-should-long-break-p ()
"Return t if current state should take a long break."
(cl-equalp pomidor--count-short-breaks (1+ pomidor-breaks-before-long)))
(defun pomidor-break-over-notify-p ()
"Return t if current break is over and user should be notified about it.
To snooze the notification use `pomidor-break'."
(and (pomidor-break-over-p) (not (pomidor-snooze-p))))
(defun pomidor-break-over-p ()
"Return t if current break is over."
(let* ((state (pomidor--current-state))
(break (pomidor--break-duration state))
(expected-break-seconds (if (pomidor-should-long-break-p)
pomidor-long-break-seconds
pomidor-break-seconds)))
(and break (> (time-to-seconds break) expected-break-seconds))))
(defun pomidor-snooze-p ()
"Return t if user snooze end of break alarm."
(plist-get (pomidor--current-state) :snooze))
(defun pomidor--total-duration (state)
"Return total time for STATE."
(time-subtract (pomidor--ended state) (pomidor--started state)))
(defun pomidor--break-duration (state)
"Return break time for STATE."
(let ((break (pomidor--break state)))
(and break (time-subtract (pomidor--ended state) break))))
(defun pomidor--format-header (time state face)
"Return formated header for TIME with FACE."
(let ((freezed-time (plist-get state :session-ended)))
(concat (pomidor--with-face (concat (pomidor--format-time (or freezed-time (current-time)))
pomidor-header-separator)
'pomidor-time-face)
(propertize (pomidor--format-duration time)
'face `(:inherit (,face pomidor-timer-face)))
(when (eq major-mode 'pomidor-history-mode)
(pomidor--with-face (concat pomidor-header-session-name-separator
(format-time-string "%Y-%m-%d" freezed-time))
'pomidor-date-face)))))
(defun pomidor--header (state)
"Return header."
(let* ((break (pomidor--break-duration state))
(overwork (pomidor--overwork-duration state))
(work (pomidor--work-duration state)))
(cond
(break (pomidor--format-header break state 'pomidor-break-face))
(overwork (pomidor--format-header overwork state 'pomidor-overwork-face))
(work (pomidor--format-header work state 'pomidor-work-face)))))
(defun pomidor--format-time (time)
"Format TIME as of `pomidor-time-format'."
(format-time-string pomidor-time-format time))
(defun pomidor--format-duration (time)
"Format TIME as of `pomidor-duration-format'.
TIME may be nil."
(format-time-string pomidor-duration-format (or time (seconds-to-time 0)) t))
(defun pomidor--window-width ()
"Return pomidor buffer width in chars."
(window-total-width (get-buffer-window (pomidor--get-buffer-create))))
(defun pomidor--with-face (string face)
"Retrun STRING with FACE."
(propertize string 'font-lock-face face))
(defun pomidor--format-time-string (time face)
"Format graph string for TIME with FACE."
(pomidor--with-face (make-string (round (/ (time-to-seconds time)
(/ (float pomidor-seconds) (/ (pomidor--window-width) 2))))
pomidor-graph-char)
face))
(defun pomidor--graph (work overwork break)
"Format graph based on WORK, OVERWORK and BREAK time."
(concat
(pomidor--format-time-string work 'pomidor-work-face)
(let ((skip (- pomidor-seconds (time-to-seconds work))))
(when (> skip 0)
(pomidor--format-time-string (seconds-to-time skip) 'pomidor-skip-face)))
(and overwork (pomidor--format-time-string overwork 'pomidor-overwork-face))
(and break (pomidor--format-time-string break 'pomidor-break-face))))
(defun pomidor--play-sound-file (file)
"Play FILE using `pomidor-play-sound-file' function if any."
(when (and file (functionp pomidor-play-sound-file))
(funcall pomidor-play-sound-file file)))
(defun pomidor--tick-tack (ellapsed)
"Play tick or tack based on ELLAPSED."
(pomidor--play-sound-file
(if (zerop (mod ellapsed 2))
pomidor-sound-tick
pomidor-sound-tack)))
(defun pomidor--update ()
"Update pomidor state."
(let* ((state (pomidor--current-state))
(total (pomidor--total-duration state))
(ellapsed (round (time-to-seconds total))))
(pomidor--tick-tack ellapsed)
(when (zerop (mod ellapsed pomidor-update-interval))
(when (functionp pomidor-alert)
(funcall pomidor-alert))
(run-hooks 'pomidor-update-hook)
(cond
((pomidor-overwork-p)
(pomidor--play-sound-file pomidor-sound-overwork))
((pomidor-break-over-notify-p)
(pomidor--play-sound-file pomidor-sound-break-over)))))
(pomidor--render (pomidor--get-buffer-create) pomidor-global-state))
(defun pomidor--render (buffer states)
"Render pomidor state."
(when (get-buffer-window buffer t)
(with-current-buffer buffer
(read-only-mode -1)
(erase-buffer)
(insert (pomidor--header (car (last states)))
"\n")
(cl-loop
for i from 1
for state in states
as work = (pomidor--work-duration state)
as overwork = (pomidor--overwork-duration state)
as break = (pomidor--break-duration state)
as total = (pomidor--total-duration state)
with sum-work = (seconds-to-time 0)
with sum-overwork = (seconds-to-time 0)
with sum-break = (seconds-to-time 0)
with sum-total = (seconds-to-time 0)
do (progn
(setq sum-work (time-add sum-work work)
sum-total (time-add sum-total total))
(when overwork
(setq sum-overwork (time-add sum-overwork overwork)))
(when break
(setq sum-break (time-add sum-break break)))
(insert
"\n "
(make-string 79 ?-)
"\n"
(format "%3d) [%s] | [%s] | [%s] | [%s]\t\t %s → %s"
i
(pomidor--with-face (pomidor--format-duration work) 'pomidor-work-face)
(pomidor--with-face (pomidor--format-duration overwork) 'pomidor-overwork-face)
(pomidor--with-face (pomidor--format-duration break) 'pomidor-break-face)
(pomidor--format-duration total)
(pomidor--format-time (pomidor--started state))
(pomidor--format-time (pomidor--ended state)))
"\n "
(pomidor--graph work overwork break)))
finally
(insert "\n "
(make-string 79 ?-)
"\n\n"
(format " Work\t[%s]\n"
(pomidor--with-face (pomidor--format-duration sum-work) 'pomidor-work-face))
(format " Overwork\t[%s]\n"
(pomidor--with-face (pomidor--format-duration sum-overwork) 'pomidor-overwork-face))
(format " Break\t[%s]\n"
(pomidor--with-face (pomidor--format-duration sum-break) 'pomidor-break-face))
(format " Total\t[%s]\n"
(pomidor--format-duration sum-total)))
)
(read-only-mode +1))))
(defun pomidor--get-buffer-create ()
"Return a pomidor buffer."
(get-buffer-create pomidor-buffer-name))
(defun pomidor--get-history-buffer-create ()
"Create a history pomidor buffer."
(get-buffer-create pomidor-history-buffer-name))
(defun pomidor--cancel-timer ()
"Cancel pomidor timer."
(when (timerp pomidor-timer)
(cancel-timer pomidor-timer)
(setq pomidor-timer nil)))
(defun pomidor--read-session (preserve-timestamp?)
"Read the saved sessions."
(let* ((json-object-type 'plist)
(json-array-type 'list)
(data (json-read-file pomidor-save-session-file)))
(if preserve-timestamp?
data
(-map (lambda (pomidor)
(-map (lambda (v) (if (stringp v)
(parse-iso8601-time-string v)
v))
pomidor))
data))))
(defun pomidor--valid-sessions-dates (session-dates direction)
"Get valid date of SESSION-DATES from history data to move in correct DIRECTION."
(let ((fun (lambda (v)
(if (cl-equalp direction :backward)
(time-less-p v pomidor--current-history-session)
(time-less-p pomidor--current-history-session v)))))
(if pomidor--current-history-session
(-filter (lambda (dt) (funcall fun dt)) session-dates)
session-dates)))
;;; Public
(defvar pomidor-mode-map
(let ((map (make-keymap)))
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "Q") #'pomidor-quit)
(define-key map (kbd "R") #'pomidor-reset)
(define-key map (kbd "h") #'pomidor-hold)
(define-key map (kbd "H") #'pomidor-unhold)
(define-key map (kbd "RET") #'pomidor-stop)
(define-key map (kbd "SPC") #'pomidor-break)
(suppress-keymap map)
map))
(defun pomidor-work-duration ()
"Return current work duration."
(pomidor--work-duration (pomidor--current-state)))
(defun pomidor-overwork-duration ()
"Return current overwork duration."
(pomidor--overwork-duration (pomidor--current-state)))
(defun pomidor-break-duration ()
"Return current break duration."
(pomidor--break-duration (pomidor--current-state)))
(defun pomidor-total-duration ()
"Return current total duration."
(pomidor--total-duration (pomidor--current-state)))
(defun pomidor-quit ()
"Turn off Pomidor."
(interactive)
(when (y-or-n-p "Are you sure you want to turn off pomidor? ")
(kill-buffer (pomidor--get-buffer-create))))
(defun pomidor--reset-long-break-counter ()
"Reset the pomidor counter after `pomidor-breaks-before-long' short breaks."
(when (pomidor-should-long-break-p)
(setq pomidor--count-short-breaks 0)))
(defun pomidor-break ()
"Break current working pomidor."
(interactive)
(let ((state (pomidor--current-state)))
(if (pomidor--break state)
(progn
(plist-put state :snooze t)
(when (or (not pomidor-confirm-end-break)
(yes-or-no-p "Stop break and start new pomidor?"))
(pomidor-stop))
(pomidor--reset-long-break-counter))
(progn
(plist-put state :break (current-time))
(setq pomidor--count-short-breaks (1+ pomidor--count-short-breaks))))))
(defun pomidor-reset ()
"Delete current global state."
(interactive)
(when (y-or-n-p "Are you sure you want reset pomidors? ")
(setq pomidor--count-short-breaks 0)
(pomidor--reset)))
(defun pomidor-stop ()
"Stop current working pomidor."
(interactive)
(let ((state (pomidor--current-state)))
(plist-put state :stopped (current-time)))
(pomidor--reset-long-break-counter)
(nconc pomidor-global-state (list (pomidor--make-state))))
(defun pomidor-hold ()
"Stop the current working pomidor and puts the system on hold."
(interactive)
(let ((state (pomidor--current-state)))
(plist-put state :stopped (current-time)))
(setq pomidor--system-on-hold-p t)
(pomidor--cancel-timer))
(defun pomidor-unhold ()
"Unhold and start a new pomidor."
(interactive)
(when pomidor--system-on-hold-p
(nconc pomidor-global-state (list (pomidor--make-state)))
(setq pomidor--system-on-hold-p nil)
(setq pomidor-timer (run-at-time nil 1 #'pomidor--update))))
(defun pomidor-save-session ()
"Save the current session in a file."
(interactive)
(let ((time-asked-to-save (current-time)))
(pomidor-quit)
(plist-put (pomidor--current-state) :stopped time-asked-to-save)
(let* ((fmt-time (lambda (time) (when time (format-time-string pomidor-history-date-format time))))
(history-state (if (file-exists-p pomidor-save-session-file)
(pomidor--read-session :preserve-iso-timestamp)
(list)))
(global-state (-map (lambda (pomidor)
(list :started (funcall fmt-time (plist-get pomidor :started))
:break (funcall fmt-time (plist-get pomidor :break))
:stopped (funcall fmt-time (plist-get pomidor :stopped))
:snooze (plist-get pomidor :snooze)
:session-ended (funcall fmt-time time-asked-to-save)))
pomidor-global-state))
(new-history (append history-state global-state))
(new-history (-filter (lambda (pomidor) (or (plist-get pomidor :stopped)
(plist-get pomidor :break)
(plist-get pomidor :snooze)))
new-history)))
(with-temp-file pomidor-save-session-file
(insert (json-encode (vconcat new-history))))))
(message "Pomidor session saved!"))
(defun pomidor-history-previous ()
"Move backward in your pomidor history."
(interactive)
(let* ((session-data (pomidor--read-session nil))
(session-dates (-map (lambda (pomidor)
(plist-get pomidor :session-ended))
session-data)))
(if (= (length session-dates) 0)
(message "You have no session saved.")
(let* ((valid-session-dates (pomidor--valid-sessions-dates session-dates :backward)))
(if valid-session-dates
(progn
(setq pomidor--current-history-session (car (last valid-session-dates)))
(pomidor--render
(pomidor--get-history-buffer-create)
(-filter (lambda (pomidor)
(equal (car (last valid-session-dates))
(plist-get pomidor :session-ended)))
session-data)))
(message "History is over, go forward."))))))
(defun pomidor-history-next ()
"Move forward in your pomidor history."
(interactive)
(let* ((session-data (pomidor--read-session nil))
(session-dates (-map (lambda (pomidor)
(plist-get pomidor :session-ended))
session-data)))
(if (= (length session-dates) 0)
(message "You have no sessions saved.")
(let* ((valid-session-dates (pomidor--valid-sessions-dates session-dates :forward)))
(if valid-session-dates
(progn
(setq pomidor--current-history-session (car valid-session-dates))
(pomidor--render
(pomidor--get-history-buffer-create)
(-filter (lambda (pomidor)
(equal (car valid-session-dates)
(plist-get pomidor :session-ended)))
session-data)))
(message "History is over, go backward."))))))
(defun pomidor-history ()
"A simple pomodoro history feature. Compare your work over time."
(interactive)
(if (not (file-exists-p pomidor-save-session-file))
(message "You should save at least one session first.")
(switch-to-buffer (pomidor--get-history-buffer-create))
(unless (eq major-mode 'pomidor-history-mode)
(pomidor-history-mode))
(pomidor-history-previous)))
(defvar pomidor-history-mode-map
(let ((map (make-keymap)))
(define-key map (kbd "q") #'quit-window)
(define-key map (kbd "n") #'pomidor-history-next)
(define-key map (kbd "p") #'pomidor-history-previous)
(suppress-keymap map)
map))
(define-derived-mode pomidor-history-mode special-mode "pomidor-history"
"Major mode for Pomidor History.
\\{pomidor-history-mode-map}"
(setq pomidor-timer nil)
(setq pomidor--current-history-session nil))
(define-derived-mode pomidor-mode special-mode "pomidor"
"Major mode for Pomidor.
\\{pomidor-mode-map}"
(setq pomidor-timer (run-at-time nil 1 #'pomidor--update))
(setq-local tab-width 8)
(add-hook 'kill-buffer-hook #'pomidor--cancel-timer nil t)
(pomidor--reset))
;;;###autoload
(defun pomidor ()
"A simple and cool pomodoro technique timer."
(interactive)
(switch-to-buffer (pomidor--get-buffer-create))
(unless (eq major-mode 'pomidor-mode)
(setq pomidor--count-short-breaks 0)
(pomidor-mode))
(pomidor--update))
(provide 'pomidor)
;;; pomidor.el ends here