git.fiddlerwoaroof.com
timesheet.lisp
92384785
 ;;;; timesheet.lisp
 
 (in-package #:timesheet)
 
2d700b03
 (ubiquitous:restore 'timesheet)
 
92384785
 ;;; "timesheet" goes here. Hacks and glory await!
 
2d700b03
 (defvar *default-time-sheet-file*)
 (defvar *rate*)
 
51faefd0
 (defmethod duration ((obj partial-entry))
   (warn "incomplete entry detected for ~a" (client obj))
   (local-time-duration:duration))
 
b953500d
 (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))
 
2d700b03
 (defun parse-file (&optional (file *default-time-sheet-file*))
d8b28a4b
   (with-open-file (s file :direction :input)
2d700b03
     (let ((dest (make-string (file-length s))))
       (read-sequence dest s)
b953500d
       (multiple-value-bind (parsed leftovers) (smug:parse (timesheet.parser::.date-records) dest)
         (unless (string= leftovers "")
a91599bf
           (cerror "Continue?" 'parsing-error :leftovers leftovers))
b953500d
         parsed))))
2d700b03
 
 (defun unroll-date (date-obj)
   (with-slots (year month day) date-obj
     (list day month year)))
 
d8b28a4b
 (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)))
 
2d700b03
 (defun calculate-ranges (ranges year month day)
d8b28a4b
   (flet ((time-mod-unit-keyword (time-mod)
            (make-keyword
              (string-upcase
                (if (string= (slot-value time-mod 'unit) "mins")
                  "minute"
                  "hour")))))
b953500d
     (loop with complete = nil
           with partial = nil
           for (start-obj end-obj mod) in ranges
d8b28a4b
           for start = (combine-date-time start-obj year month day)
b953500d
           for end = (when end-obj (combine-date-time end-obj year month day))
d8b28a4b
           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)))
b953500d
           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)))))
2d700b03
 
 (defun calculate-rounded-ranges (ranges)
   (flet ((calc-duration-in-15mins (duration)
            (let ((duration-in-minutes (local-time-duration:duration-as duration :minute)))
b953500d
              (coerce (/ (round duration-in-minutes 15) 4)
2d700b03
                      'float))))
     (calc-duration-in-15mins
       (reduce #'local-time-duration:duration+ ranges
         :initial-value (local-time-duration:duration)))))
 
b953500d
 (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))))))))))
 
2d700b03
 (defun get-log (&optional (file *default-time-sheet-file*))
   (block nil
b953500d
      (let* ((entries (parse-file file)))
        (loop for entry in entries
              for ranges = (get-entry-ranges entry)
              append ranges))))
2d700b03
 
 (defparameter +pprint-log-option-spec+
d8b28a4b
   '((("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
0bd25181
                     :documentation "Print a summary of the hours worked and the prices")
d8b28a4b
     (("help" #\h) :type boolean :optional t :documentation "show help")))
2d700b03
 
 (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))
 
d8b28a4b
 (defun sort-by-date (results)
   (stable-sort results #'local-time:timestamp<
                :key (alambda (apply #'local-time:encode-timestamp
                                     (append '(0 0 0 0)
b953500d
                                             (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)
a91599bf
   (: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))))
b953500d
 
 (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))))
 
a91599bf
 (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
b953500d
      (format nil "~26<Total~>:~7,2F hours @ ~7,2F $/hr = $~7,2F"
a91599bf
              total-hours rate (* rate total-hours)))))
 
 (defgeneric calculate-cost (calc time)
   (:method ((calc status-calculator) (status-line status-line))
    (* (rate calc) (duration status-line))))
b953500d
 
 (defun print-status (results)
a91599bf
   (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*)))))
b953500d
 
 (defun pprint-results (results incompletes status)
a91599bf
   (print-entries results)
b953500d
 
   (when incompletes
a91599bf
     (format t "~&~120,1,0,'-<~>~%Partial Entries:~%")
b953500d
     (print-entries incompletes))
 
   (when status
     (print-status results)))
 
a91599bf
 (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
d8b28a4b
 
 (defun pprint-log (args &key client reverse status help)
   (when help
     (show-help)
     (return-from pprint-log))
 
b953500d
   (flet ((sort-results (results &optional (client client))
d8b28a4b
            (setf results (sort-by-date results))
            (when client
b953500d
              (setf results (stable-sort results #'string-lessp :key #'client)))
d8b28a4b
            (when reverse
              (setf results (nreverse results)))
            results))
 
b953500d
     (let ((*default-time-sheet-file* (or (car args) *default-time-sheet-file*))
           (*print-pretty* t))
a91599bf
       (destructuring-bind (complete-ranges incomplete-ranges) (group-by-class (get-log *default-time-sheet-file*))
b953500d
         (let ((complete-results (sort-results complete-ranges))
               (incomplete-results (sort-results incomplete-ranges t)))
           (pprint-results complete-results incomplete-results status))))))
2d700b03
 
 (defun pprint-log-main (argv)
d8b28a4b
   (setf *rate* (ubiquitous:defaulted-value 0 :rate)
         *default-time-sheet-file* (ubiquitous:defaulted-value #p"~/time.md" :timesheet :file))
2d700b03
   (command-line-arguments:handle-command-line
     +pprint-log-option-spec+
     'pprint-log
     :command-line (cdr argv)
     :name "timesheet"
     :rest-arity t))
d8b28a4b