git.fiddlerwoaroof.com
Browse code

Handle incomplete and invalid entries nicely

fiddlerwoaroof authored on 27/02/2016 14:09:34
Showing 4 changed files
... ...
@@ -57,7 +57,9 @@
57 57
 (make-simple-equality day-entry :test ==)
58 58
 (make-simple-equality time-record :test ==)
59 59
 (make-simple-equality time-obj :test eql)
60
-(make-simple-equality date-obj :test eql)
60
+(make-equality date-obj
61
+  (day-of-week eql)
62
+  (year) (month) (day))
61 63
 (make-simple-equality time-mod :test equal)
62 64
 
63 65
  
... ...
@@ -78,8 +80,21 @@
78 80
       (setf amount amnt unit unt)
79 81
       it)))
80 82
 
83
+(define-condition parse-error () ())
84
+
85
+(define-condition invalid-day-of-week (parse-error)
86
+  ((day-of-week :initarg :day-of-week :reader day-of-week))
87
+  (:report (lambda (condition stream)
88
+             (format stream "~s is not a valid day of the week"
89
+                     (day-of-week condition)))))
90
+
81 91
 (defun make-date-obj (day-of-week year month day)
82
-  (make-instance 'date-obj :day-of-week day-of-week :year year :month month :day day))
92
+  (let ((day-of-week (subseq day-of-week 0 3)))
93
+    (if (member day-of-week
94
+                '("Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun")
95
+                :test #'string-equal)
96
+      (make-instance 'date-obj :day-of-week day-of-week :year year :month month :day day)  
97
+      (error 'invalid-day-of-week :day-of-week day-of-week))))
83 98
 
84 99
 (defun make-time-obj (hour minute &optional second)
85 100
   (make-instance 'time-obj :hour hour :minute minute :second second))
... ...
@@ -103,22 +118,26 @@
103 118
             year month day))))
104 119
 
105 120
 (define-printer (time-obj s)
106
-  ()
121
+  ((with-slots (hour minute second) time-obj
122
+    (format s "~2,'0d:~2,'0d:~2,'0d"  hour minute second))) 
107 123
   ((with-slots (hour minute second) time-obj
108 124
     (format s "~2,'0d:~2,'0d:~2,'0d"  hour minute second))))
109 125
 
110 126
 (define-printer (day-entry s)
111
-  ()
127
+  ((with-slots (date records) day-entry
128
+    (format s "~d records for ~s" (length records) date))) 
112 129
   ((with-slots (date records) day-entry
113 130
     (format s "~d records for ~s" (length records) date))))
114 131
 
115 132
 (define-printer (time-record s)
116
-  ()
133
+  ((with-slots (client) time-record
134
+    (format s "For ~s" client))) 
117 135
   ((with-slots (client) time-record
118 136
     (format s "For ~s" client))))
119 137
 
120 138
 (define-printer (time-mod s)
121
-  ()
139
+  ((with-slots (amount unit) time-mod
140
+    (format s "~s ~s" amount unit))) 
122 141
   ((with-slots (amount unit) time-mod
123 142
     (format s "~s ~s" amount unit))))
124 143
 
... ...
@@ -314,21 +333,41 @@
314 333
     (.identity (coerce (list fi se) 'string))) )
315 334
 
316 335
 (defun .date-separator ()
317
-  (.char= #\-))
336
+  (.or (.char= #\-)
337
+       (.char= #\/)))
318 338
 
319 339
 (defun .date ()
320 340
   (.let* ((dow (.weekday))
341
+          (_ (.optional (.char= #\,)))
321 342
           (_ (.char= #\Space))
322 343
           (year (.year))
323
-          (_ (.date-separator))
344
+          (sep1 (.date-separator))
324 345
           (month (.month))
325
-          (_ (.date-separator))
346
+          (sep2 (.date-separator))
326 347
           (day (.day)))
327 348
     (let ((year (parse-integer year))
328 349
           (month (parse-integer month))
329 350
           (day (parse-integer day)))
330 351
       (.identity (make-date-obj dow year month day)))))
331 352
 
353
+(st:deftest date-test ()
354
+  
355
+  (st:should be == nil
356
+             (caar (smug:run (.date) "Monday 2020/01-01")))
357
+   
358
+
359
+  (st:should be == (make-date-obj "Monday" 2020 01 01)
360
+             (caar (smug:run (.date) "Monday, 2020-01-01")))
361
+   
362
+
363
+  (st:should be == (make-date-obj "Monday" 2020 01 01)
364
+             (caar (smug:run (.date) "Monday 2020-01-01")))
365
+   
366
+
367
+  (st:should be == (make-date-obj "Monday" 2020 01 01) 
368
+             (caar (smug:run (.date) "Monday 2020/01/01")))
369
+  )
370
+
332 371
 (defun .date-start ()
333 372
   (.string= "-- "))
334 373
 
... ...
@@ -348,9 +387,9 @@
348 387
 (defun .parse-all-records ()
349 388
   (.prog1 (.date-records) (.not (.item))))
350 389
 
351
-(defun parse (data)
352
-  (alet (run (.date-records) data)
353
-    (values (caar it) (cdar it))))
390
+#|(defun parse (data)|#
391
+#|  (alet (run (.date-records) data)|#
392
+#|    (values (caar it) (cdar it))))|#
354 393
 
355 394
 ;; This will help make sure everything is consumed when
356 395
 ;; we don't care about the parser's output.
... ...
@@ -594,6 +633,15 @@
594 633
   (st:should be eql t (== #("1") #("1")))
595 634
   (st:should be eql t (== '(1 . 2) '(1 . 2)))
596 635
   (st:should be eql t (== '((1 . 2)) '((1 . 2))))
636
+
637
+  (st:should be eql t
638
+             (== (make-date-obj "Monday" 2012 01 01)
639
+                 (make-date-obj "Monday" 2012 01 01)))
640
+
641
+  (st:should be eql t
642
+             (== (make-time-obj 00 00 00)
643
+                 (make-time-obj 00 00 00)))
644
+
597 645
   (st:should be eql t
598 646
              (== (make-time-mod 3 "mins")
599 647
                  (make-time-mod 3 "mins"))) 
600 648
Binary files a/precompiled/timesheet and b/precompiled/timesheet differ
... ...
@@ -7,7 +7,7 @@
7 7
    Client #1: Prototype for testing user experience.
8 8
 
9 9
 -- Wednesday 2016-01-03
10
-   start@08:00--12:00,15:30--17:00+30min
10
+   start@08:00--12:00,15:30--17:00+30min,16:00--
11 11
    Client #1: Delivered prototype, reviewed prototype feedback.
12 12
    start@12:30--15:00
13 13
    Client #2: Discussed user requirements and produce specification.
... ...
@@ -15,10 +15,10 @@
15 15
 -- Thursday 2016-01-04
16 16
    start@08:00--16:54
17 17
    Client #1: Implement Facebook Connector
18
-   start@17:00--21:54
18
+   start@17:00--21:54,22:00--
19 19
    Client #2: Implement prototype and write presentation
20 20
 
21 21
 -- Friday 2016-01-05
22
-   start@11:00--16:54
22
+   start@11:00--16:54,17:00--
23 23
    Client #1: Implement Twitter Connector
24 24
 
... ...
@@ -9,11 +9,57 @@
9 9
 (defvar *default-time-sheet-file*)
10 10
 (defvar *rate*)
11 11
 
12
+(defclass parsed-entry ()
13
+  ((date :initarg :date :accessor date)
14
+   (client :initarg :client :accessor client)
15
+   (memo :initarg :memo :accessor memo)))
16
+
17
+(defclass complete-entry (parsed-entry)
18
+  ((duration :initarg :duration :accessor duration)))
19
+
20
+(defclass partial-entry (parsed-entry)
21
+  ((start-times :initarg :start-times :initform nil :accessor start-times)))
22
+
23
+(defun make-complete-entry (date client memo duration)
24
+  (make-instance 'complete-entry
25
+                 :date date
26
+                 :client client
27
+                 :memo memo
28
+                 :duration duration))
29
+
30
+(defun make-partial-entry (date client memo start-times)
31
+  (make-instance 'partial-entry
32
+                 :date date
33
+                 :client client
34
+                 :memo memo
35
+                 :start-times start-times))
36
+
37
+(defmacro /. (&rest body)
38
+  (let ((args '())
39
+        forms)
40
+    (loop for (head . tail) on body
41
+          until (eql head '->)
42
+          do (push head args)
43
+          finally (setf args (nreverse args))
44
+          finally (setf forms tail))
45
+    `(macrolet
46
+       ((>< (&rest form)
47
+          (list* (cadr form) (car form) (cddr form))))
48
+       (lambda ,args ,@forms))))
49
+
50
+(define-condition parse-error ()
51
+  ((leftovers :initarg :leftovers :accessor leftovers))
52
+  (:report (lambda (condition stream)
53
+               (format stream "Parse error: ~20s leftover" (leftovers condition)))))
54
+
12 55
 (defun parse-file (&optional (file *default-time-sheet-file*))
13 56
   (with-open-file (s file :direction :input)
14 57
     (let ((dest (make-string (file-length s))))
15 58
       (read-sequence dest s)
16
-      (caar (smug:run (timesheet.parser::.date-records) dest)))))
59
+      (multiple-value-bind (parsed leftovers) (smug:parse (timesheet.parser::.date-records) dest)
60
+        (unless (string= leftovers "")
61
+          (cerror "Continue?" 'parse-error :leftovers leftovers))
62
+        parsed))))
17 63
 
18 64
 (defun unroll-date (date-obj)
19 65
   (with-slots (year month day) date-obj
... ...
@@ -31,39 +77,47 @@
31 77
                (if (string= (slot-value time-mod 'unit) "mins")
32 78
                  "minute"
33 79
                  "hour")))))
34
-    (loop for (start-obj end-obj mod) in ranges
80
+    (loop with complete = nil
81
+          with partial = nil
82
+          for (start-obj end-obj mod) in ranges
35 83
           for start = (combine-date-time start-obj year month day)
36
-          for end = (combine-date-time end-obj year month day)
84
+          for end = (when end-obj (combine-date-time end-obj year month day))
37 85
           for time-mod = (when mod
38 86
                            (let ((unit (time-mod-unit-keyword mod))
39 87
                                  (amount (slot-value mod 'timesheet.parser:amount)))
40 88
                              (funcall #'local-time-duration:duration unit amount)))
41
-          nconc (list
42
-                  (local-time-duration:timestamp-difference end start)
43
-                  (or time-mod (local-time-duration:duration))))))
89
+          if end do (push (local-time-duration:timestamp-difference end start) complete)
90
+          else do (push start partial)
91
+          when time-mod do (push time-mod complete)
92
+          finally (return (values complete partial)))))
44 93
 
45 94
 (defun calculate-rounded-ranges (ranges)
46 95
   (flet ((calc-duration-in-15mins (duration)
47 96
            (let ((duration-in-minutes (local-time-duration:duration-as duration :minute)))
48
-             (coerce (/ (round duration-in-minutes  15) 4)
97
+             (coerce (/ (round duration-in-minutes 15) 4)
49 98
                      'float))))
50 99
     (calc-duration-in-15mins
51 100
       (reduce #'local-time-duration:duration+ ranges
52 101
         :initial-value (local-time-duration:duration)))))
53 102
 
103
+(defun get-entry-ranges (entry)
104
+  (let ((date (slot-value entry 'date)))
105
+    (with-slots (year month day) date
106
+      (loop for record in (slot-value entry 'records)
107
+            append (with-slots (client memo ranges) record
108
+                     (multiple-value-bind (complete partial) (calculate-ranges ranges day month year)
109
+                       (list*
110
+                         (make-complete-entry date client memo (calculate-rounded-ranges complete))
111
+                         (when partial
112
+                           (list
113
+                             (make-partial-entry date client memo partial))))))))))
114
+
54 115
 (defun get-log (&optional (file *default-time-sheet-file*))
55 116
   (block nil
56
-         (let* ((entries (parse-file file)))
57
-           (loop for entry in entries
58
-                 for date = (slot-value entry 'date)
59
-                 nconc (with-slots (year month day) date
60
-                         (loop for record in (slot-value entry 'records)
61
-                               collect (with-slots (client memo ranges) record
62
-                                         `(,date
63
-                                            ,client
64
-                                            ,(calculate-rounded-ranges
65
-                                               (calculate-ranges ranges day month year))
66
-                                            ,memo))))))))
117
+     (let* ((entries (parse-file file)))
118
+       (loop for entry in entries
119
+             for ranges = (get-entry-ranges entry)
120
+             append ranges))))
67 121
 
68 122
 (defparameter +pprint-log-option-spec+
69 123
   '((("client" #\c) :type boolean :optional t :documentation "Sort by client")
... ...
@@ -84,57 +138,106 @@
84 138
   (stable-sort results #'local-time:timestamp<
85 139
                :key (alambda (apply #'local-time:encode-timestamp
86 140
                                     (append '(0 0 0 0)
87
-                                            (unroll-date (car it)))))))
88
-
89
-(defun pprint-results (results status)
90
-  (let ((clients (make-hash-table))
91
-        (total-cost 0))
92
-
93
-    (labels ((record-client (client hours)
94
-               (let ((client (make-keyword (string-upcase client))))
95
-                 (incf (gethash client clients 0) hours)))
96
-             (total-line (results)
97
-               (format nil "~26<Total~>:~7,2F hours @ ~7,2F $/hr = $~7,2F"
98
-                       (loop for (_ client time __) in results
99
-                             do (progn _ __)
100
-                             sum time
101
-                             do (record-client client time)
102
-                             do (incf total-cost (* time *rate*)))
103
-                       *rate*
104
-                       total-cost))
105
-             (fix-assoc (alist)
106
-               (mapcar (destructuring-lambda ((client . time))
107
-                         (list client time *rate* (* time *rate*)))
108
-                       alist)))
109
-
110
-      (format t "~&~:{~4a ~10<~:(~a~)~> ~7,2F hrs ~a~%~}" results)
111
-      (when status
112
-        (format t "~120,1,0,'-<~>")
113
-        (let ((total (total-line results)))
114
-          (format t "~&~:{~:(~26<~a~>~):~7,2F hours @ ~7,2F $/hr = $~7,2F~%~}"
115
-                  (stable-sort
116
-                    (fix-assoc (hash-table-alist clients))
117
-                    #'string<
118
-                    :key (alambda (car it))))
119
-          (format t total))))))
141
+                                            (unroll-date (date it)))))))
142
+
143
+(defun group-by-client (incompletes)
144
+  (let ((results (make-hash-table :test 'equalp)))
145
+    (loop for incomplete in incompletes
146
+          do (push incomplete (gethash (client incomplete) results)))
147
+    (hash-table-alist results)))
148
+
149
+(defgeneric print-entries (entries)
150
+  (:method ((incompletes list))
151
+   (format t "~&~120,1,0,'-<~>~%Partial Entries:~%")
152
+   (loop for (client . entries) in (group-by-client incompletes)
153
+         do  (loop for entry in entries
154
+                   do (format t "~&~4<~>~a, ~a:~%~{~12<~>one starting at ~a~%~}"
155
+                              (client entry)
156
+                              (memo entry)
157
+                              (mapcar
158
+                                (alambda (local-time:format-timestring
159
+                                           nil it
160
+                                           :format '(:year #\/ (:month 2) #\/ (:day 2) #\Space
161
+                                                     (:hour 2) #\: (:min 2) #\: (:sec 2))))
162
+                                (start-times entry)))))))
163
+
164
+(defclass status-calculator ()
165
+  ((clients :initform (make-hash-table) :accessor clients)
166
+   (total-cost :initform 0 :accessor total-cost)))
167
+
168
+(defgeneric record-client (calc client hours)
169
+  (:method ((calc status-calculator) client hours)
170
+    (let ((client (make-keyword (string-upcase client))))
171
+      (incf (gethash client (clients calc) 0)
172
+            hours))))
173
+
174
+(defgeneric total-line (calc results)
175
+  (:method ((calc status-calculator) results)
176
+   (with-accessors ((total-cost total-cost)) calc
177
+     (format nil "~26<Total~>:~7,2F hours @ ~7,2F $/hr = $~7,2F"
178
+             (loop for result in results
179
+                   do (record-client calc (client result) (duration result))
180
+                   do (incf total-cost (* (duration result) *rate*))
181
+                   sum (duration result))
182
+             *rate*
183
+             total-cost))))
184
+
185
+(defun print-status (results)
186
+  (let ((status-calculator (make-instance 'status-calculator)))
187
+    (flet ((fix-assoc (alist)
188
+             (mapcar (destructuring-lambda ((client . time))
189
+                       (list client
190
+                             time
191
+                             *rate*
192
+                             (* time *rate*)))
193
+                     alist)))
194
+      (let ((total (total-line status-calculator results)))
195
+        (format t "~&~120,1,0,'-<~>~%~:{~:(~26<~a~>~):~7,2F hours @ ~7,2F $/hr = $~7,2F~%~}"
196
+          (stable-sort (fix-assoc (hash-table-alist (clients status-calculator)))
197
+                       #'string<
198
+                       :key (alambda (car it))))
199
+        (format t total)))))
200
+
201
+(defun pprint-results (results incompletes status)
202
+  (format t "~&~:{~4a ~10<~:(~a~)~> ~7,2F hrs ~a~%~}"
203
+          (mapcar (alambda (list (date it) (client it) (duration it) (memo it)))
204
+                  results))
205
+
206
+  (when incompletes
207
+    (print-entries incompletes))
208
+
209
+  (when status
210
+    (print-status results)))
211
+
212
+(defun group-by-class (list)
213
+  (loop with completes = '()
214
+        with partials  = '()
215
+        with complete-class = (find-class 'complete-entry)
216
+        with partial-class = (find-class 'partial-entry)
217
+        for el in list
218
+        when (eq (class-of el) complete-class) do (push el completes)
219
+        when (eq (class-of el) partial-class)  do (push el partials)
220
+        finally (return (values completes partials))))
120 221
 
121 222
 (defun pprint-log (args &key client reverse status help)
122 223
   (when help
123 224
     (show-help)
124 225
     (return-from pprint-log))
125 226
 
126
-  (flet ((sort-results (results)
227
+  (flet ((sort-results (results &optional (client client))
127 228
            (setf results (sort-by-date results))
128 229
            (when client
129
-             (setf results (stable-sort results #'string-lessp :key #'cadr)))
230
+             (setf results (stable-sort results #'string-lessp :key #'client)))
130 231
            (when reverse
131 232
              (setf results (nreverse results)))
132 233
            results))
133 234
 
134
-    (let* ((*default-time-sheet-file* (or (car args) *default-time-sheet-file*))
135
-           (*print-pretty* t)
136
-           (results (sort-results (get-log *default-time-sheet-file*))))
137
-      (pprint-results results status))))
235
+    (let ((*default-time-sheet-file* (or (car args) *default-time-sheet-file*))
236
+          (*print-pretty* t))
237
+      (multiple-value-bind (complete-ranges incomplete-ranges) (group-by-class (get-log *default-time-sheet-file*))
238
+        (let ((complete-results (sort-results complete-ranges))
239
+              (incomplete-results (sort-results incomplete-ranges t)))
240
+          (pprint-results complete-results incomplete-results status))))))
138 241
 
139 242
 (defun pprint-log-main (argv)
140 243
   (setf *rate* (ubiquitous:defaulted-value 0 :rate)