Criteria:
Every year divisible by 4 is a leap year, except when it is divisible by 100 unless it is 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 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 should all pass validation:
1976-02-29
2000-02-29
2004-02-29
1999-01-31
The following dates should all fail validation:
2015-02-29
2015-04-31
1900-02-29
1999-01-32
2015-02-00
Range:
We'll test for dates from 1st Jan 1000 to 31st Dec 2999. Technically the currently used Gregorian calendar only came into use in 1753 for the British Empire and at various years in the 1600s for countries in Europe, but I'm not going to worry about that.
Regex to test for a leap year:
The 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
The years divisible by 4:
[12]\d([02468][048]|[13579][26])
The years divisible by 100:
[12]\d00
Not divisible by 100:
[12]\d([1-9]\d|\d[1-9])
The years divisible by 100 but not by 400:
((1[1345789])|(2[1235679]))00
Divisible by 4 but not by 100:
[12]\d([2468][048]|[13579][26]|0[48])
The 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 there you have it a regex for dates between 1st Jan 1000 and 31st Dec 2999 in YYYY-MM-DD format.
I suspect it can be shortened quite a bit, but I'll leave that up to somebody else.
That will match all valid dates. If you want it to only be valid when it contains just one date and nothing else, then wrap it in ^( )$
like so:
^(((((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 it for an optional date entry (ie. it can be blank or a valid date) then add ^$|
at the beginning, like so:
^$|^(((((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])))))$