Criteria:
Each year divisible by 4 is a leap year, except when it is divisible by 100, if it is not divisible by 400. So:
2004 - leap year - divisible by 4 1900 - not a leap year - divisible by 4, but also divisible by 100 2000 - leap year - divisible by 4, also divisible by 100, but divisible by 400
February has 29 days in a leap year and 28 when not in a leap year
30 days in April, June, September and November
31 days in January, March, May, July, August, October and December
Test:
The following dates must pass verification:
1976-02-29 2000-02-29 2004-02-29 1999-01-31
The following dates must pass verification:
2015-02-29 2015-04-31 1900-02-29 1999-01-32 2015-02-00
Range:
We will check the dates from January 1 to December 31, December 29, 2999. The Gregorian calendar, currently used technically, was used only in 1753 for the British Empire and in different years in the 1600s for European countries, but I will not worry about that.
Lege year Regex:
Years divisible by 400:
1200|1600|2000|2400|2800 can be shortened to: (1[26]|2[048])00 if you wanted all years from 1AD to 9999 then this would do it: (0[48]|[13579][26]|[2468][048])00 if you're happy with accepting 0000 as a valid year then it can be shortened: ([13579][26]|[02468][048])00
Years divisible by 4:
[12]\d([02468][048]|[13579][26])
Years divisible by 100:
[12]\d00
Not divisible by 100:
[12]\d([1-9]\d|\d[1-9])
Years divisible by 100, but not by 400:
((1[1345789])|(2[1235679]))00
Divisible by 4, but not 100:
[12]\d([2468][048]|[13579][26]|0[48])
Leap years:
divisible by 400 or (divisible by 4 and not divisible by 100) ((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48])
Not divisible by 4:
[12]\d([02468][1235679]|[13579][01345789])
Not a leap year:
Not divisible by 4 OR is divisible by 100 but not by 400 ([12]\d([02468][1235679]|[13579][01345789]))|(((1[1345789])|(2[1235679]))00)
Valid month and day excluding February (MM-DD):
((01|03|05|07|08|10|12)-(0[1-9]|[12]\d|3[01]))|((04|06|09|11)-(0[1-9]|[12]\d|30)) shortened to: ((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30))
February with 28 days:
02-(0[1-9]|1\d|2[0-8])
February with 29 days:
02-(0[1-9]|[12]\d)
Valid date:
(leap year followed by (valid month-day-excluding-february OR 29-day-february)) OR (non leap year followed by (valid month-day-excluding-february OR 28-day-february)) ((((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48]))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|[12]\d))))|((([12]\d([02468][1235679]|[13579][01345789]))|((1[1345789]|2[1235679])00))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|1\d|2[0-8]))))
So, you have a regular expression for dates between January 1, 1000 and December 31, 2999 in the format YYYY-MM-DD.
I suspect that it can be reduced a little, but I will leave it to someone else.
This will correspond to all valid dates. If you want it to be valid only if it contains only one date and nothing else, then wrap it in ^( )$ as follows:
^(((((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48]))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|[12]\d))))|((([12]\d([02468][1235679]|[13579][01345789]))|((1[1345789]|2[1235679])00))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|1\d|2[0-8])))))$
If you want an additional date entry (that is, it can be an empty or a valid date), add ^$| at the beginning, for example:
^$|^(((((1[26]|2[048])00)|[12]\d([2468][048]|[13579][26]|0[48]))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|[12]\d))))|((([12]\d([02468][1235679]|[13579][01345789]))|((1[1345789]|2[1235679])00))-((((0[13578]|1[02])-(0[1-9]|[12]\d|3[01]))|((0[469]|11)-(0[1-9]|[12]\d|30)))|(02-(0[1-9]|1\d|2[0-8])))))$