Browse code
Handle incomplete and invalid entries nicely
fiddlerwoaroof authored on 27/02/2016 14:09:34
Showing 4 changed files
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"))) |
... | ... |
@@ -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) |