package ca.sqlpower.util;
import java.text.DateFormat;
import java.text.DateFormatSymbols;
import java.util.Calendar;
import java.util.Date;
import java.util.GregorianCalendar;
import org.apache.log4j.Logger;
/**
* The Recurrence class represents the ocurrence of an event at some
* time which can recur at some configurable interval.
*/
public class Recurrence {
private static Logger logger = Logger.getLogger(Recurrence.class);
/**
* This is the date and time of the first occurrence. The
* time-of-day component also determines the time of day for
* future occurrences.
*/
protected Date startDate;
/**
* This is an optional end date (and time) past which no further
* occurrences are scheduled.
*/
protected Date endDate;
/**
* This is the recurrence frequency. Supported values are
* Frequency.DAILY, Frequency.WEEKLY, Frequency.MONTHLY,
* Frequency.YEARLY. Note that QUARTERLY is not supported.
*/
protected Frequency frequency;
/**
* This is the interval (based on the current frequency) of
* recurrence. For example, if frequency is MONTHLY and interval
* is 2, the recurrence will be scheduled every two months.
*/
protected int interval;
/**
* <code>byDate</code> modifies the way monthly and yearly
* recurrences work. If startDate is December 18, 2003 (the third
* Thursday of December 2003) and frequency is <code>YEARLY</code>, a
* recurrence <code>byDate</code> would be December 18, 2004 (not a Thursday).
* On the other hand, if <code>byDate</code> was
* <code>false</code>, the next occurrence would be December 16,
* 2004 (the third Thursday).
*/
protected boolean byDate;
/**
* For WEEKLY recurrences, this array holds the days of the week
* that the recurrence will happen on. These values are not
* returned directly by {@link #isOnDay(int)}, because the day of
* the week for startDate is always a recurrence day.
*/
protected boolean[] day;
/**
* Sets up a new recurrence which is initially scheduled every day
* at the current time of day forever.
*/
public Recurrence() {
startDate = new Date();
endDate = null;
frequency = new Frequency(Frequency.DAILY);
interval = 1;
byDate = false;
day = new boolean[Calendar.SATURDAY+1];
}
/**
* Prints out an English, human-readable representation of this recurrence.
*/
public String toString() {
OrdinalNumberFormat ordinalFmt = new OrdinalNumberFormat();
DateFormatSymbols dfs = new DateFormatSymbols();
String[] weekdays = dfs.getWeekdays();
String[] months = dfs.getMonths();
StringBuffer sb = new StringBuffer();
Calendar startCal = new GregorianCalendar();
startCal.setTime(startDate);
sb.append("[Recurrence: every ");
switch (frequency.getFreq()) {
case Frequency.DAILY:
sb.append(interval > 1 ? interval+" days" : "day");
break;
case Frequency.WEEKLY:
if (interval > 1) {
sb.append(interval+" weeks on ");
}
for (int d = Calendar.SUNDAY, n = 0; d <= Calendar.SATURDAY; d++) {
if (isOnDay(d)) {
if (n>0) sb.append(", ");
sb.append(weekdays[d]);
n++;
}
}
break;
case Frequency.MONTHLY:
sb.append(interval > 1 ? interval+" months" : "month");
if (byDate) {
int date = startCal.get(Calendar.DATE);
sb.append(" on the ").append(ordinalFmt.format(date));
} else {
int dayInMonth = startCal.get(Calendar.DAY_OF_WEEK_IN_MONTH);
sb.append(" on the ").append(ordinalFmt.format(dayInMonth)).append(" ")
.append(weekdays[startCal.get(Calendar.DAY_OF_WEEK)]);
}
break;
case Frequency.YEARLY:
sb.append(interval > 1 ? interval+" years" : "year");
if (byDate) {
int month = startCal.get(Calendar.MONTH);
int date = startCal.get(Calendar.DATE);
sb.append(" on ").append(months[month]).append(" ")
.append(ordinalFmt.format(date));
} else {
int month = startCal.get(Calendar.MONTH);
int dayInMonth = startCal.get(Calendar.DAY_OF_WEEK_IN_MONTH);
sb.append(" on the ").append(ordinalFmt.format(dayInMonth)).append(" ")
.append(weekdays[startCal.get(Calendar.DAY_OF_WEEK)])
.append(" of ").append(months[month]);
}
break;
default:
throw new IllegalStateException("Unsupported recurrence frequency "+frequency);
}
sb.append(" at ").append(DateFormat.getTimeInstance().format(startDate));
sb.append(" starting ").append(DateFormat.getDateInstance()
.format(startDate));
if (endDate != null) {
sb.append(" until ").append(DateFormat.getDateInstance()
.format(endDate));
}
sb.append("]");
return sb.toString();
}
/**
* Returns an array of <code>n</code> elements which are the exact
* dates of the next <code>n</code> occurrences of this
* recurrence. If the recurrence will expire before n more
* occurrences, those array slots will contain <code>null</code>.
*/
public Date[] nextOccurrences(Date baseDate, int n) {
Date[] retval = new Date[n];
for (int i = 0; i < n && baseDate != null; i++) {
baseDate = nextOccurrence(baseDate);
retval[i] = baseDate;
}
return retval;
}
/**
* Computes and returns the date and time of the next occurrence
* of this Recurrence instance, relative to the current system date.
*/
public Date nextOccurrence() {
return nextOccurrence(new Date());
}
/**
* Computes and returns the date and time of the next occurrence
* of this Recurrence instance, relative to an arbitrary base date.
*
* <p>Calculating the next occurrence is a constant time operation
* for all supported frequencies, intervals, and date settings.
*
* @param baseDate The date returned will be the next occurrence
* after this date.
* @throws IllegalStateException if the recurrence frequency is
* unsupported. Supported frequencies are daily, weekly, monthly,
* and yearly, but not quarterly.
*/
public Date nextOccurrence(Date baseDate) {
logger.debug("Calc next occurrence from base="+baseDate
+"; start="+startDate
+"; end="+(endDate==null?"never":endDate.toString()));
// Simple base cases
if (endDate != null && baseDate.after(endDate)) return null;
if (baseDate.before(startDate)) return startDate;
// ok, now we have to actually think (well, *I* have to think. the computer just runs instructions either way)
Calendar next = new GregorianCalendar(); // we will alter and return this value
next.setTime(startDate);
switch (frequency.getFreq()) {
default:
long dif;
int days;
int weeks;
int months;
int years;
GregorianCalendar base;
GregorianCalendar start;
throw new IllegalStateException("Unsupported recurrence frequency "+frequency);
case Frequency.DAILY:
// figures out next occurrence by calculating days from start until base
dif = baseDate.getTime() - startDate.getTime();
days = 1 + (int) (dif / (1000 * 60 * 60 * 24));
logger.debug("base date is "+days+" days ("+dif+"ms) after start date");
if (interval > 1) {
days = days + interval - (days % interval);
}
next.add(Calendar.DATE, days);
break;
case Frequency.WEEKLY:
/* this one is the most complicated because it's different.
* General idea:
* 1. Let w be the calendar week of base
* 2. Let ww be the closest scheduled interval week on or after w
* 3. let d be { w==ww: day of week of base; w!=ww: sunday }
* 4. let dd be the next recurrence day after d. if there are no more
* recurrence days after d, let dd be the first recurrence day.
* 5. let www be { w==ww and dd < d: ww + interval; else: ww }
* 6. set next week = www; day of week = dd.
*
* Note: when I say "recurrence week" or "recurrence day" I mean a week
* or day of week for which the user has scheduled an occurrence of this
* object. For example, if interval == 2, then every other week is a
* recurrence week.
*/
start = new GregorianCalendar();
start.setTime(startDate);
start.set(Calendar.DAY_OF_WEEK, Calendar.SUNDAY);
start.set(Calendar.HOUR, 0);
start.set(Calendar.MINUTE, 0);
start.set(Calendar.SECOND, 0);
start.set(Calendar.MILLISECOND, 0);
base = new GregorianCalendar();
base.setTime(baseDate);
// calculate weeks from start to base (required for determining ww)
dif = baseDate.getTime() - start.getTime().getTime();
weeks = (int) (dif / (1000 * 60 * 60 * 24 * 7));
logger.debug("base date is "+weeks+" weeks ("+dif+"ms) after start date");
int w = start.get(Calendar.WEEK_OF_YEAR) + weeks; // base week in start year
int ww; // next scheduled recurrence week (will be w if w is a recurrence week)
if ((interval > 1) && (weeks % interval != 0)) {
ww = start.get(Calendar.WEEK_OF_YEAR) + (weeks + interval - (weeks % interval));
} else {
ww = w;
}
int d; // Calendar.SUNDAY == 1 .. Calendar.SATURDAY == 7
if (w == ww) {
d = base.get(Calendar.DAY_OF_WEEK);
} else {
d = Calendar.SUNDAY;
}
int dd = d;
do {
dd += 1;
if (dd > Calendar.SATURDAY) dd = Calendar.SUNDAY;
} while (!isOnDay(dd));
int www;
if (w == ww && dd <= d) {
// day of week wrapped around, so we're into the next week now.
www = ww + interval;
} else {
www = ww;
}
if (logger.isDebugEnabled()) {
logger.debug("Base week = "+w);
logger.debug("Next Recurrence week = "+ww);
logger.debug("Searching day-of-week starting with "
+new DateFormatSymbols().getWeekdays()[d]);
logger.debug("Selected next day-of-week "
+new DateFormatSymbols().getWeekdays()[dd]);
logger.debug("Final choice of next week-of-year "+www);
}
next.set(Calendar.DAY_OF_WEEK, dd);
next.set(Calendar.WEEK_OF_YEAR, www); // bridges to new year with www > 52
break;
case Frequency.MONTHLY:
/* not quite as easy as DAILY because months have variable
* length and there is a user-settable byDate mode that
* only affects MONTHLY and YEARLY.
*/
base = new GregorianCalendar();
base.setTime(baseDate);
start = new GregorianCalendar();
start.setTime(startDate);
months = monthsFromStart(base); // properly handles both byDate modes
logger.debug("months from start to base = "+months);
if (months % interval != 0) {
months = months + interval - (months % interval);
}
logger.debug("next occurrence will be "+months+" months after start");
next.set(Calendar.DATE, 1); // fake date for now; it is set after bumping the month
next.add(Calendar.MONTH, months);
if (byDate) {
next.set(Calendar.DATE, start.get(Calendar.DATE));
} else {
next.set(Calendar.DAY_OF_WEEK, start.get(Calendar.DAY_OF_WEEK));
next.set(Calendar.DAY_OF_WEEK_IN_MONTH, start.get(Calendar.DAY_OF_WEEK_IN_MONTH));
}
break;
case Frequency.YEARLY:
// works similarly to the MONTHLY case
base = new GregorianCalendar();
base.setTime(baseDate);
start = new GregorianCalendar();
start.setTime(startDate);
months = monthsFromStart(base); // properly handles both byDate modes
logger.debug("months from start to base = "+months);
years = months / 12;
if (months % 12 != 0) {
years += 1;
}
if (years % interval != 0) {
years = years + interval - (years % interval);
}
logger.debug("next occurrence will be "+years+" years after start");
next.set(Calendar.DATE, 1); // fake date for now; it is set after bumping the year
next.add(Calendar.YEAR, years);
if (byDate) {
next.set(Calendar.DATE, start.get(Calendar.DATE));
} else {
next.set(Calendar.DAY_OF_WEEK, start.get(Calendar.DAY_OF_WEEK));
next.set(Calendar.DAY_OF_WEEK_IN_MONTH, start.get(Calendar.DAY_OF_WEEK_IN_MONTH));
}
break;
}
return next.getTime();
}
/**
* Calculates and returns the number of milliseconds from midnight
* to the time-of-day stored in cal.
*
* @param cal A Calendar instance. This method only uses its
* time-of-day fields.
* @return An integer between 0 and 86499999 inclusive.
*/
protected int msSinceMidnight(Calendar cal) {
int ms = cal.get(Calendar.MILLISECOND);
ms += 1000 * cal.get(Calendar.SECOND);
ms += 1000 * 60 * cal.get(Calendar.MINUTE);
ms += 1000 * 60 * 60 * cal.get(Calendar.HOUR);
return ms;
}
/**
* Calculates how many months are between this Recurrence's start
* date and the given calendar. Partial months are counted as
* full months. For example, there is 1 month between 2004-01-02
* and 2004-02-01 and 1 month between 2004-01-01 and 2004-01-02.
* There are 3 months between 2003-12-01 and 2004-02-14.
*
* <p>This method behaves differently depending on the setting of
* <code>byDate</code>. If <code>byDate</code> is set, the month
* boundary is the same time-of-day on the same date of following
* months. For instance, the month following 2004-01-06 10:00
* starts at 2004-02-06 10:00. The next month after that starts
* at 2004-03-06 10:00. If <code>byDate</code> is not set, then
* the month boundary is at the same time-of-day on the same
* day-of-week-in-month of following months. For instance, the
* month following 2004-01-06 10:00 (the first Tuesday of January)
* starts at 2004-02-03 10:00 (the first Tuesday of February).
* The next following month starts at 2004-03-02 (the first
* Tuesday of March).
*
* @param second a date after this recurrence's start date.
* @return the number of months from the start date to the given
* date. If cal represents a moment in time before this.startDate,
* the return value is undefined.
*/
public int monthsFromStart(Calendar cal) {
GregorianCalendar start = new GregorianCalendar();
start.setTime(startDate);
int months = 12 * (cal.get(Calendar.YEAR) - start.get(Calendar.YEAR));
months += cal.get(Calendar.MONTH) - start.get(Calendar.MONTH);
if (byDate) {
if (start.get(Calendar.DATE) < cal.get(Calendar.DATE)
|| (start.get(Calendar.DATE) == cal.get(Calendar.DATE)
&& msSinceMidnight(start) <= msSinceMidnight(cal))) {
months += 1;
}
} else {
logger.debug("start DAY_OF_WEEK_IN_MONTH "+start.get(Calendar.DAY_OF_WEEK_IN_MONTH)
+"; DAY_OF_WEEK "+start.get(Calendar.DAY_OF_WEEK));
logger.debug("cal DAY_OF_WEEK_IN_MONTH "+cal.get(Calendar.DAY_OF_WEEK_IN_MONTH)
+"; DAY_OF_WEEK "+cal.get(Calendar.DAY_OF_WEEK));
if (start.get(Calendar.DAY_OF_WEEK_IN_MONTH) < cal.get(Calendar.DAY_OF_WEEK_IN_MONTH)
|| (start.get(Calendar.DAY_OF_WEEK_IN_MONTH) == cal.get(Calendar.DAY_OF_WEEK_IN_MONTH)
&& start.get(Calendar.DAY_OF_WEEK) < cal.get(Calendar.DAY_OF_WEEK)
|| (start.get(Calendar.DAY_OF_WEEK) == cal.get(Calendar.DAY_OF_WEEK)
&& msSinceMidnight(start) <= msSinceMidnight(cal)))) {
months += 1;
}
}
return months;
}
// -------------------- Accessors and Mutators ------------------
/**
* Gets the value of startDate
*
* @return the value of startDate
*/
public Date getStartDate() {
return this.startDate;
}
/**
* Sets the value of startDate
*
* @param argStartDate Value to assign to this.startDate
*/
public void setStartDate(Date argStartDate) {
this.startDate = argStartDate;
}
/**
* Gets the value of endDate
*
* @return the value of endDate
*/
public Date getEndDate() {
return this.endDate;
}
/**
* Sets the value of endDate
*
* @param argEndDate Value to assign to this.endDate
*/
public void setEndDate(Date argEndDate) {
this.endDate = argEndDate;
}
/**
* Gets the value of frequency
*
* @return the value of frequency
*/
public Frequency getFrequency() {
return this.frequency;
}
/**
* Sets the value of frequency
*
* @param argFrequency Value to assign to this.frequency
*/
public void setFrequency(Frequency argFrequency) {
this.frequency = argFrequency;
}
/**
* Gets the recurrence interval. See {@link #interval}.
*
* @return the value of interval
*/
public int getInterval() {
return this.interval;
}
/**
* Sets the recurrence interval. See {@link #interval}.
*
* @param argInterval A positive integer.
* @throws IllegalArgumentException if argInterval < 1.
*/
public void setInterval(int argInterval) {
if (argInterval < 1) {
throw new IllegalArgumentException("Interval must be a positive integer");
} else {
this.interval = argInterval;
}
}
/**
* Gets the value of byDate
*
* @return the value of byDate
*/
public boolean isByDate() {
return this.byDate;
}
/**
* Sets the value of byDate
*
* @param argByDate Value to assign to this.byDate
*/
public void setByDate(boolean argByDate) {
this.byDate = argByDate;
}
/**
* Figures out whether or not this recurrence happens on a given
* day of the week. A weekly recurrence always occurs on the
* day-of-week of its start date, plus any other days as set using
* {@link #setOnDay(int,boolean)}. This method is only useful
* when the recurrence frequency is WEEKLY; returns false when
* this recurrence is set to any other frequency.
*
* @param dayNum A constant from java.util.Calendar representing
* the day of the week such as Calendar.TUESDAY.
* @throws IndexOutOfBoundsException if dayNum is not a valid
* day of the week.
*/
public boolean isOnDay(int dayNum) {
if (frequency.getFreq() == Frequency.WEEKLY) {
GregorianCalendar startCal = new GregorianCalendar();
startCal.setTime(startDate);
return day[dayNum] || dayNum == startCal.get(Calendar.DAY_OF_WEEK);
} else {
return false;
}
}
/**
* Sets or clears the given day-of-week for this recurrence.
* Trying to turn off the day of the week that startDate falls on
* has no effect; the other six days of the week can be switched
* on and off at your whim.
*/
public void setOnDay(int dayNum, boolean v) {
day[dayNum] = v;
}
}