View Javadoc

1   /*
2    * Copyright (C) 2007 Alf Mikula
3    * 
4    * This file is part of PromoteGo.
5    *
6    * PromoteGo is free software: you can redistribute it and/or modify
7    * it under the terms of the GNU General Public License as published by
8    * the Free Software Foundation, either version 3 of the License, or
9    * (at your option) any later version.
10   *
11   * PromoteGo is distributed in the hope that it will be useful,
12   * but WITHOUT ANY WARRANTY; without even the implied warranty of
13   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14   * GNU General Public License for more details.
15   *
16   * You should have received a copy of the GNU General Public License
17   * along with PromoteGo.  If not, see <http://www.gnu.org/licenses/>.
18   */
19  package org.promotego.logic.storehours;
20  
21  import java.util.ArrayList;
22  import java.util.Arrays;
23  import java.util.Collection;
24  import java.util.Collections;
25  import java.util.HashSet;
26  import java.util.LinkedList;
27  import java.util.List;
28  import java.util.Set;
29  
30  import org.apache.commons.lang.math.IntRange;
31  
32  /***
33   * A single specification of store hours, featuring hours for one day or
34   * same hours for a set of days.  Generates IntRange objects representing
35   * the start and end seconds on a weekly schedule, and also contains helper
36   * methods for merging multiple days' schedules into a single HourSpecification
37   * object for nicer printing.
38   * 
39   * @author alf
40   */
41  public class HourSpecification
42  {
43  	private static int SECONDS_IN_DAY = 24*3600;
44  	private static IntRangeComparator s_intRangeComparator = new IntRangeComparator();
45  
46  	/***
47  	 * There are only 7 days, so having an initial size of 10 and default
48  	 * load factor of 0.75 means we can hold all 7 days without resizing
49  	 * the set's hash table.
50  	 */
51  	private Set<Day> m_days = new HashSet<Day>(10);
52  	private List<IntRange> m_ranges = new ArrayList<IntRange>(2);
53  	
54  	private HourSpecification()
55  	{ }
56  	
57  	public HourSpecification(String stringSpec)
58  	{
59  		String [] components = stringSpec.split(" +");
60  		if (components.length != 2)
61  		{
62  			throw new IllegalArgumentException("Wrong number of space-separated tokens in string: " + stringSpec);
63  		}
64  		
65  		String [] dayRanges = components[0].split(",");
66  		for (String thisRange : dayRanges)
67  		{
68  			addDayRange(thisRange);
69  		}
70  		
71  		String [] hourRanges = components[1].split(",");
72  		for (String thisRange : hourRanges)
73  		{
74  			// Get base range from hours string
75  			IntRange theBaseRange = getRange(thisRange);
76  			
77  			addHourRange(theBaseRange);
78  		}
79  	}
80  	
81  	public HourSpecification(Collection<Day> days, List<IntRange> ranges)
82  	{
83  		m_days.addAll(days);
84  		for (IntRange thisRange : ranges)
85  		{
86  			addHourRange(thisRange);
87  		}
88  	}
89  
90  	/***
91  	 * @param dayRange
92  	 */
93  	private void addDayRange(String dayRange)
94  	{
95  		// Split on dash to get day range.
96  		String [] days = dayRange.split("-");
97  		if (days.length > 2)
98  		{
99  			throw new IllegalArgumentException("Too many dash-separated days in string: " + dayRange);
100 		}
101 		
102 		// Resolve the start day
103 		Day startResult = Day.mapAbbreviation(days[0]);
104 		if (startResult == null)
105 		{
106 			throw new IllegalArgumentException("Illegal day string: " + days[0]);
107 		}
108 		
109 		int startDay = startResult.ordinal();
110 		
111 		// Resolve the end day, if present.
112 		int endDay;
113 		if (days.length > 1)
114 		{
115 			Day endResult = Day.mapAbbreviation(days[1]);
116 			if (endResult == null)
117 			{
118 				throw new IllegalArgumentException("Illegal day string: " + days[1]);
119 			}
120 			
121 			endDay = endResult.ordinal();
122 		}
123 		else
124 		{
125 			endDay = startDay;
126 		}
127 
128 		// We'll calculate day modulo 7 below to keep it 0-6.
129 		if (endDay < startDay)
130 		{
131 			endDay += 7;
132 		}
133 		
134 		for (int theDay = startDay; theDay <= endDay; theDay++)
135 		{
136 			m_days.add(Day.values()[theDay%7]);
137 		}
138 	}
139 	
140 	/***
141 	 * @param theRange
142 	 */
143 	private void addHourRange(IntRange theRange)
144 	{
145 		int searchResult = Collections.binarySearch(m_ranges, theRange, s_intRangeComparator);
146 		
147 		if (searchResult >= 0)
148 		{
149 			// The given range intersects with the range at the result index.
150 			IntRange currentRange = m_ranges.get(searchResult);
151 			if (currentRange.containsRange(theRange))
152 			{
153 				// Nothing to do: the range provided doesn't add any open time.
154 			}
155 			else if (theRange.containsRange(currentRange))
156 			{
157 				// Replace the range in the list with the new range
158 				m_ranges.remove(searchResult);
159 				m_ranges.add(searchResult, theRange);
160 			}
161 			else
162 			{
163 				// Create a new range with the minimum of the two minimums and the maximum of the two maximums.
164 				int newMinimum = minimum(currentRange.getMinimumInteger(), theRange.getMinimumInteger());
165 				int newMaximum = maximum(currentRange.getMaximumInteger(), theRange.getMaximumInteger());
166 				IntRange newRange = new IntRange(newMinimum, newMaximum);
167 				m_ranges.remove(searchResult);
168 				m_ranges.add(searchResult, newRange);
169 			}
170 		}
171 		else
172 		{
173 			// No intersection, and search has returned -(insertionPoint + 1)
174 			int insertionPoint = -searchResult - 1;
175 			m_ranges.add(insertionPoint, theRange);
176 		}
177 	}
178 
179 	private int minimum(int a, int b)
180 	{
181 		if (a < b)
182 		{
183 			return a;
184 		}
185 		else
186 		{
187 			return b;
188 		}
189 	}
190 	
191 	private int maximum(int a, int b)
192 	{
193 		if (a > b)
194 		{
195 			return a;
196 		}
197 		else
198 		{
199 			return b;
200 		}
201 	}
202 
203 	/***
204 	 * Given a range of hours in format HH[:MM]-HH[:MM], return an <code>IntRange</code>
205 	 * representing the time in seconds of the hours given.  Hours are in 24-hour format,
206 	 * and an end time before the start time represents an end time the next day, such
207 	 * as 9-1:30, meaning the store closes at 1:30 am the following morning.
208 	 * 
209 	 * @param string The hours to parse, in format HH[:MM]-HH[:MM].
210 	 * @return An <code>IntRange</code> representing the start and end times in seconds.
211 	 */
212 	private IntRange getRange(String hourRange)
213 	{
214 		String [] hours = hourRange.split("-");
215 		
216 		if (hours.length != 2)
217 		{
218 			throw new IllegalArgumentException("Illegal hour range string: " + hourRange);
219 		}
220 		
221 		int startHour = toSeconds(hours[0]);
222 		int endHour = toSeconds(hours[1]);
223 		if (endHour <= startHour)
224 		{
225 			// Strings like 10:00-1:30 represent a closing time the following morning.
226 			endHour += SECONDS_IN_DAY;
227 		}
228 		
229 		return new IntRange(startHour, endHour);
230 	}
231 
232 	/***
233 	 * Returns the time in seconds since midnight represented by the <code>String</code> passed in.
234 	 * 
235 	 * @param string A time, in HH:MM format.
236 	 * @return The time in seconds since midnight.
237 	 */
238 	private int toSeconds(String string)
239 	{
240 		String [] components = string.split(":");
241 		
242 		if (components.length > 2)
243 		{
244 			throw new IllegalArgumentException("Illegal hour string: " + string);
245 		}
246 		
247 		int retval;
248 		
249 		try
250 		{
251 			int hour = Integer.parseInt(components[0]);
252 			retval = hour*3600;
253 			if (components.length > 1)
254 			{
255 				int minutes = Integer.parseInt(components[1]);
256 				retval += minutes*60;
257 			}
258 		} catch (NumberFormatException e)
259 		{
260 			throw new IllegalArgumentException("Illegal hour string: " + string);
261 		}
262 
263 		return retval;
264 	}
265 
266 	@Override
267 	public boolean equals(Object obj)
268 	{
269 		if (obj == this)
270 		{
271 			return true;
272 		}
273 		
274 		if (!(obj instanceof HourSpecification))
275 		{
276 			return false;
277 		}
278 		
279 		HourSpecification otherSpec = (HourSpecification) obj;
280 		return m_days.equals(otherSpec.m_days) && m_ranges.equals(otherSpec.m_ranges);
281 	}
282 
283 	@Override
284 	public int hashCode()
285 	{
286 		return m_days.hashCode() ^ m_ranges.hashCode();
287 	}
288 	
289 	@Override
290 	public String toString()
291 	{
292 		return getDaysString() + " " + getHoursString();
293 	}
294 	
295 	private String getDaysString()
296 	{
297 		List<IntRange> daysList = new LinkedList<IntRange>();
298 		
299 		Day [] days = m_days.toArray(new Day[m_days.size()]);
300 		Arrays.sort(days);
301 		Day startDay = null;
302 		Day endDay = null;
303 		for (Day thisDay : days)
304 		{
305 			if (startDay == null)
306 			{
307 				// Initialization
308 				startDay = thisDay;
309 				endDay = thisDay;
310 			}
311 			else
312 			{
313 				if (thisDay.ordinal() - endDay.ordinal() > 1)
314 				{
315 					// Current day breaks the range.
316 					// enter previous range
317 					daysList.add(new IntRange(startDay.ordinal(), endDay.ordinal()));
318 					
319 					// Start a new range
320 					startDay = thisDay;
321 					endDay = thisDay;
322 				}
323 				else
324 				{
325 					// Still building the range.  Add the current day.
326 					endDay = thisDay;
327 				}
328 			}
329 		}
330 		
331 		// Enter the range that was being built when the loop terminated.
332 		assert (startDay != null && endDay != null) : "HourSpecification must have at least one day";
333 		
334 		daysList.add(new IntRange(startDay.ordinal(), endDay.ordinal()));
335 		
336 		StringBuilder retval = new StringBuilder(12);
337 		if (daysList.size() >= 2 && daysList.get(0).getMinimumInteger() == 0
338 				&& daysList.get(daysList.size()-1).getMaximumInteger() == 6)
339 		{
340 			// First range starts with Sunday and last range ends with Saturday.  Merge them.
341 			IntRange lastRange = daysList.remove(daysList.size() - 1);
342 			IntRange firstRange = daysList.remove(0);
343 			
344 			retval.append(Day.values()[lastRange.getMinimumInteger()].getAbbreviation());
345 			retval.append("-");
346 			retval.append(Day.values()[firstRange.getMaximumInteger()].getAbbreviation());
347 		}
348 		
349 		// Now iterate through the rest of the ranges.
350 		for (IntRange thisRange : daysList)
351 		{
352 			if (retval.length() > 0)
353 			{
354 				retval.append(",");
355 			}
356 			
357 			startDay = Day.values()[thisRange.getMinimumInteger()];
358 			endDay = Day.values()[thisRange.getMaximumInteger()];
359 			retval.append(startDay.getAbbreviation());
360 			if (!startDay.equals(endDay))
361 			{
362 				retval.append("-");
363 				retval.append(endDay.getAbbreviation());
364 			}
365 		}
366 		
367 		return retval.toString();
368 	}
369 	
370 	private String getHoursString()
371 	{
372 		StringBuilder retval = new StringBuilder(16);
373 		
374 		for (IntRange thisRange : m_ranges)
375 		{
376 			if (retval.length() > 0)
377 			{
378 				retval.append(",");
379 			}
380 			
381 			retval.append(toHourString(thisRange.getMinimumInteger()));
382 			retval.append("-");
383 			retval.append(toHourString(thisRange.getMaximumInteger()));
384 		}
385 		
386 		return retval.toString();
387 	}
388 
389 	/***
390 	 * @param minimumInteger
391 	 * @return
392 	 */
393 	private String toHourString(int minimumInteger)
394 	{
395 		// Size to hold HH:MM
396 		StringBuilder retval = new StringBuilder(5);
397 		int hour = minimumInteger/3600;
398 		if (hour >= 24)
399 		{
400 			hour -= 24;
401 		}
402 		retval.append(hour);
403 		
404 		int leftoverSeconds = minimumInteger%3600;
405 		if (leftoverSeconds > 60)
406 		{
407 			retval.append(":");
408 			retval.append(leftoverSeconds/60);
409 		}
410 		return retval.toString();
411 	}
412 	
413 	public boolean sameDays(HourSpecification otherSpec)
414 	{
415 		return m_days.equals(otherSpec.m_days);
416 	}
417 	
418 	public boolean sameHours(HourSpecification otherSpec)
419 	{
420 		return m_ranges.equals(otherSpec.m_ranges);
421 	}
422 
423 	/***
424 	 * Return a new HourSpecification with all days and hour ranges of this
425 	 * HourSpecification and the one provided.  This operation is idempotent
426 	 * with respect to both HourSpecification objects used to calculate the
427 	 * new HourSpecification.
428 	 * 
429 	 * @param otherSpec
430 	 * @return
431 	 */
432 	public HourSpecification mergeWith(HourSpecification otherSpec)
433 	{
434 		HourSpecification retval = new HourSpecification();
435 		
436 		retval.m_days.addAll(m_days);
437 		retval.m_days.addAll(otherSpec.m_days);
438 		
439 		retval.m_ranges.addAll(m_ranges);
440 		for (IntRange thisRange : otherSpec.m_ranges)
441 		{
442 			retval.addHourRange(thisRange);
443 		}
444 		
445 		return retval;
446 	}
447 	
448 	public Set<Day> getDays()
449 	{
450 		return new HashSet<Day>(m_days);
451 	}
452 	
453 	public List<IntRange> getSecondRanges()
454 	{
455 		return new ArrayList<IntRange>(m_ranges);
456 	}
457 }