;;;; timesheet.lisp (in-package #:timesheet) (ubiquitous:restore 'timesheet) ;;; "timesheet" goes here. Hacks and glory await! (defvar *default-time-sheet-file*) (defvar *rate*) (defmethod duration ((obj partial-entry)) (warn "incomplete entry detected for ~a" (client obj)) (local-time-duration:duration)) (defun make-complete-entry (date client memo duration) (make-instance 'complete-entry :date date :client client :memo memo :duration duration)) (defun make-partial-entry (date client memo start-times) (make-instance 'partial-entry :date date :client client :memo memo :start-times start-times)) (defun parse-file (&optional (file *default-time-sheet-file*)) (with-open-file (s file :direction :input) (let ((dest (make-string (file-length s)))) (read-sequence dest s) (multiple-value-bind (parsed leftovers) (smug:parse (timesheet.parser::.date-records) dest) (unless (string= leftovers "") (cerror "Continue?" 'parsing-error :leftovers leftovers)) parsed)))) (defun unroll-date (date-obj) (with-slots (year month day) date-obj (list day month year))) (defun combine-date-time (time-obj day month year) (with-slots (second minute hour) time-obj (local-time:encode-timestamp 0 second minute hour day month year))) (defun calculate-ranges (ranges year month day) (flet ((time-mod-unit-keyword (time-mod) (make-keyword (string-upcase (if (string= (slot-value time-mod 'unit) "mins") "minute" "hour"))))) (loop with complete = nil with partial = nil for (start-obj end-obj mod) in ranges for start = (combine-date-time start-obj year month day) for end = (when end-obj (combine-date-time end-obj year month day)) for time-mod = (when mod (let ((unit (time-mod-unit-keyword mod)) (amount (slot-value mod 'timesheet.parser:amount))) (funcall #'local-time-duration:duration unit amount))) if end do (push (local-time-duration:timestamp-difference end start) complete) else do (push start partial) when time-mod do (push time-mod complete) finally (return (values complete partial))))) (defun calculate-rounded-ranges (ranges) (flet ((calc-duration-in-15mins (duration) (let ((duration-in-minutes (local-time-duration:duration-as duration :minute))) (coerce (/ (round duration-in-minutes 15) 4) 'float)))) (calc-duration-in-15mins (reduce #'local-time-duration:duration+ ranges :initial-value (local-time-duration:duration))))) (defun get-entry-ranges (entry) (let ((date (slot-value entry 'date))) (with-slots (year month day) date (loop for record in (slot-value entry 'records) append (with-slots (client memo ranges) record (multiple-value-bind (complete partial) (calculate-ranges ranges day month year) (list* (make-complete-entry date client memo (calculate-rounded-ranges complete)) (when partial (list (make-partial-entry date client memo partial)))))))))) (defun get-log (&optional (file *default-time-sheet-file*)) (block nil (let* ((entries (parse-file file))) (loop for entry in entries for ranges = (get-entry-ranges entry) append ranges)))) (defparameter +pprint-log-option-spec+ '((("client" #\c) :type boolean :optional t :documentation "Sort by client") (("reverse" #\r) :type boolean :optional t :documentation "Reverse sort") (("status" #\s) :type boolean :optional t :documentation "Print a summary of the hours worked and the prices") (("help" #\h) :type boolean :optional t :documentation "show help"))) (defparameter *version* "0:1") (defun show-version () (format t "timesheet, common-lisp version ~a~%" *version*)) (defun show-help () (show-version) (command-line-arguments:show-option-help +pprint-log-option-spec+ :sort-names t)) (defun sort-by-date (results) (stable-sort results #'local-time:timestamp< :key (alambda (apply #'local-time:encode-timestamp (append '(0 0 0 0) (unroll-date (date it))))))) (defun group-by-client (incompletes) (let ((results (make-hash-table :test 'equalp))) (loop for incomplete in incompletes do (push incomplete (gethash (client incomplete) results))) (hash-table-alist results))) (defgeneric print-entries (entries) (:method ((entries list)) (mapcar #'print-entries entries)) (:method ((entry partial-entry)) (format t "~&~4<~>~a, ~a:~%~{~12<~>one starting at ~a~%~}" (client entry) (memo entry) (mapcar (alambda (local-time:format-timestring nil it :format '(:year #\/ (:month 2) #\/ (:day 2) #\Space (:hour 2) #\: (:min 2) #\: (:sec 2)))) (start-times entry)))) (:method ((it complete-entry)) (format t "~&~4a ~10<~:(~a~)~> ~7,2F hrs ~a~%" (date it) (client it) (duration it) (memo it)))) (defgeneric record-client (calc client hours) (:method ((calc status-calculator) client hours) (let ((client (make-keyword (string-upcase client)))) (incf (gethash client (clients calc) 0) hours)))) (defgeneric update (calculator entry) (:method ((calculator status-calculator) entry) (incf (total-hours calculator) (duration entry))) (:method ((calculator status-line) entry) (incf (duration calculator) (duration entry)))) (defun update-clients (clients-hash-table entry) (with-accessors ((client client)) entry (update (ensure-gethash client clients-hash-table (make-instance 'status-line :client client)) entry))) (defun calculate-results (results &optional (rate *rate*)) (let ((status-calculator (make-instance 'status-calculator :rate rate :client-totals (make-hash-table :test 'equalp)))) (prog1 status-calculator (loop for entry in results do (update-clients (client-totals status-calculator) entry) do (update status-calculator entry))))) (defgeneric total-line (calc rate) (:method ((calc status-calculator) rate) (with-accessors ((total-hours total-hours)) calc (format nil "~26:~7,2F hours @ ~7,2F $/hr = $~7,2F" total-hours rate (* rate total-hours))))) (defgeneric calculate-cost (calc time) (:method ((calc status-calculator) (status-line status-line)) (* (rate calc) (duration status-line)))) (defun print-status (results) (let* ((status-calculator (calculate-results results))) (flet ((print-status-line (status-line) (format t "~&~:(~26<~a~>~):~7,2F hours @ ~7,2F $/hr = $~7,2F~%" (client status-line) (duration status-line) (rate status-calculator) (calculate-cost status-calculator status-line)))) (format t "~&~120,1,0,'-<~>~%") (loop with client-totals = (client-totals status-calculator) for client in (sort (hash-table-keys client-totals) #'string-lessp) for status-line = (gethash client client-totals) do (print-status-line status-line)) (format t (total-line status-calculator *rate*))))) (defun pprint-results (results incompletes status) (print-entries results) (when incompletes (format t "~&~120,1,0,'-<~>~%Partial Entries:~%") (print-entries incompletes)) (when status (print-status results))) (defun group-by-class (list &optional accum1 accum2) (tagbody ; Let's do some TCO ... start (if (null list) (return-from group-by-class (list accum1 accum2)) (destructuring-bind (head . tail) list (etypecase head (complete-entry (setf accum1 (cons head accum1))) ; Here we set the accumulators (partial-entry (setf accum2 (cons head accum2)))) ; to the appropriate values. (setf list tail) ; Here we step towards the terminating condition (go start))))) ; Recurse (defun pprint-log (args &key client reverse status help) (when help (show-help) (return-from pprint-log)) (flet ((sort-results (results &optional (client client)) (setf results (sort-by-date results)) (when client (setf results (stable-sort results #'string-lessp :key #'client))) (when reverse (setf results (nreverse results))) results)) (let ((*default-time-sheet-file* (or (car args) *default-time-sheet-file*)) (*print-pretty* t)) (destructuring-bind (complete-ranges incomplete-ranges) (group-by-class (get-log *default-time-sheet-file*)) (let ((complete-results (sort-results complete-ranges)) (incomplete-results (sort-results incomplete-ranges t))) (pprint-results complete-results incomplete-results status)))))) (defun pprint-log-main (argv) (setf *rate* (ubiquitous:defaulted-value 0 :rate) *default-time-sheet-file* (ubiquitous:defaulted-value #p"~/time.md" :timesheet :file)) (command-line-arguments:handle-command-line +pprint-log-option-spec+ 'pprint-log :command-line (cdr argv) :name "timesheet" :rest-arity t))