Spaces:
Running
Running
Compute deadline based on local time zone
Browse files- README.md +1 -1
- package-lock.json +22 -6
- package.json +2 -1
- src/components/ConferenceCard.tsx +31 -9
- src/components/ConferenceDialog.tsx +21 -13
- src/utils/dateUtils.ts +126 -0
README.md
CHANGED
@@ -108,7 +108,7 @@ You can see it in your web browser at http://localhost:8080/.
|
|
108 |
|
109 |
## Deploy on the cloud
|
110 |
|
111 |
-
One way to deploy this on a cloud is by using [Artifact Registry](https://cloud.google.com/artifact-registry/docs) (for hosting the Docker image) and [Cloud Run](https://cloud.google.com/run?hl=en) (a serverless service by Google to run Docker containers).
|
112 |
|
113 |
Make sure to:
|
114 |
- create a [Google Cloud project](https://console.cloud.google.com/)
|
|
|
108 |
|
109 |
## Deploy on the cloud
|
110 |
|
111 |
+
One way to deploy this on a cloud is by using [Artifact Registry](https://cloud.google.com/artifact-registry/docs) (for hosting the Docker image) and [Cloud Run](https://cloud.google.com/run?hl=en) (a serverless service by Google to run Docker containers). See [this YouTube video](https://youtu.be/cw34KMPSt4k?si=UbzNRobNzib93uDl) for a nice intro.
|
112 |
|
113 |
Make sure to:
|
114 |
- create a [Google Cloud project](https://console.cloud.google.com/)
|
package-lock.json
CHANGED
@@ -40,7 +40,8 @@
|
|
40 |
"class-variance-authority": "^0.7.1",
|
41 |
"clsx": "^2.1.1",
|
42 |
"cmdk": "^1.0.0",
|
43 |
-
"date-fns": "^
|
|
|
44 |
"embla-carousel-react": "^8.3.0",
|
45 |
"input-otp": "^1.2.4",
|
46 |
"lucide-react": "^0.462.0",
|
@@ -4126,13 +4127,28 @@
|
|
4126 |
}
|
4127 |
},
|
4128 |
"node_modules/date-fns": {
|
4129 |
-
"version": "
|
4130 |
-
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-
|
4131 |
-
"integrity": "sha512-
|
4132 |
"license": "MIT",
|
|
|
|
|
|
|
|
|
|
|
|
|
4133 |
"funding": {
|
4134 |
-
"type": "
|
4135 |
-
"url": "https://
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4136 |
}
|
4137 |
},
|
4138 |
"node_modules/debug": {
|
|
|
40 |
"class-variance-authority": "^0.7.1",
|
41 |
"clsx": "^2.1.1",
|
42 |
"cmdk": "^1.0.0",
|
43 |
+
"date-fns": "^2.30.0",
|
44 |
+
"date-fns-tz": "^2.0.0",
|
45 |
"embla-carousel-react": "^8.3.0",
|
46 |
"input-otp": "^1.2.4",
|
47 |
"lucide-react": "^0.462.0",
|
|
|
4127 |
}
|
4128 |
},
|
4129 |
"node_modules/date-fns": {
|
4130 |
+
"version": "2.30.0",
|
4131 |
+
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
4132 |
+
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
4133 |
"license": "MIT",
|
4134 |
+
"dependencies": {
|
4135 |
+
"@babel/runtime": "^7.21.0"
|
4136 |
+
},
|
4137 |
+
"engines": {
|
4138 |
+
"node": ">=0.11"
|
4139 |
+
},
|
4140 |
"funding": {
|
4141 |
+
"type": "opencollective",
|
4142 |
+
"url": "https://opencollective.com/date-fns"
|
4143 |
+
}
|
4144 |
+
},
|
4145 |
+
"node_modules/date-fns-tz": {
|
4146 |
+
"version": "2.0.1",
|
4147 |
+
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.1.tgz",
|
4148 |
+
"integrity": "sha512-fJCG3Pwx8HUoLhkepdsP7Z5RsucUi+ZBOxyM5d0ZZ6c4SdYustq0VMmOu6Wf7bli+yS/Jwp91TOCqn9jMcVrUA==",
|
4149 |
+
"license": "MIT",
|
4150 |
+
"peerDependencies": {
|
4151 |
+
"date-fns": "2.x"
|
4152 |
}
|
4153 |
},
|
4154 |
"node_modules/debug": {
|
package.json
CHANGED
@@ -43,7 +43,8 @@
|
|
43 |
"class-variance-authority": "^0.7.1",
|
44 |
"clsx": "^2.1.1",
|
45 |
"cmdk": "^1.0.0",
|
46 |
-
"date-fns": "^
|
|
|
47 |
"embla-carousel-react": "^8.3.0",
|
48 |
"input-otp": "^1.2.4",
|
49 |
"lucide-react": "^0.462.0",
|
|
|
43 |
"class-variance-authority": "^0.7.1",
|
44 |
"clsx": "^2.1.1",
|
45 |
"cmdk": "^1.0.0",
|
46 |
+
"date-fns": "^2.30.0",
|
47 |
+
"date-fns-tz": "^2.0.0",
|
48 |
"embla-carousel-react": "^8.3.0",
|
49 |
"input-otp": "^1.2.4",
|
50 |
"lucide-react": "^0.462.0",
|
src/components/ConferenceCard.tsx
CHANGED
@@ -3,6 +3,7 @@ import { Conference } from "@/types/conference";
|
|
3 |
import { formatDistanceToNow, parseISO, isValid, isPast } from "date-fns";
|
4 |
import ConferenceDialog from "./ConferenceDialog";
|
5 |
import { useState } from "react";
|
|
|
6 |
|
7 |
const ConferenceCard = ({
|
8 |
title,
|
@@ -21,11 +22,27 @@ const ConferenceCard = ({
|
|
21 |
...conferenceProps
|
22 |
}: Conference) => {
|
23 |
const [dialogOpen, setDialogOpen] = useState(false);
|
24 |
-
const deadlineDate = deadline
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
29 |
|
30 |
// Create location string by concatenating city and country
|
31 |
const location = [city, country].filter(Boolean).join(", ");
|
@@ -33,10 +50,15 @@ const ConferenceCard = ({
|
|
33 |
// Determine countdown color based on days remaining
|
34 |
const getCountdownColor = () => {
|
35 |
if (!deadlineDate || !isValid(deadlineDate)) return "text-neutral-600";
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
|
|
|
|
|
|
|
|
|
|
40 |
};
|
41 |
|
42 |
const handleCardClick = (e: React.MouseEvent) => {
|
|
|
3 |
import { formatDistanceToNow, parseISO, isValid, isPast } from "date-fns";
|
4 |
import ConferenceDialog from "./ConferenceDialog";
|
5 |
import { useState } from "react";
|
6 |
+
import { getDeadlineInLocalTime } from '@/utils/dateUtils';
|
7 |
|
8 |
const ConferenceCard = ({
|
9 |
title,
|
|
|
22 |
...conferenceProps
|
23 |
}: Conference) => {
|
24 |
const [dialogOpen, setDialogOpen] = useState(false);
|
25 |
+
const deadlineDate = getDeadlineInLocalTime(deadline, timezone);
|
26 |
+
|
27 |
+
// Add validation before using formatDistanceToNow
|
28 |
+
const getTimeRemaining = () => {
|
29 |
+
if (!deadlineDate || !isValid(deadlineDate)) {
|
30 |
+
return 'TBD';
|
31 |
+
}
|
32 |
+
|
33 |
+
if (isPast(deadlineDate)) {
|
34 |
+
return 'Deadline passed';
|
35 |
+
}
|
36 |
+
|
37 |
+
try {
|
38 |
+
return formatDistanceToNow(deadlineDate, { addSuffix: true });
|
39 |
+
} catch (error) {
|
40 |
+
console.error('Error formatting time remaining:', error);
|
41 |
+
return 'Invalid date';
|
42 |
+
}
|
43 |
+
};
|
44 |
+
|
45 |
+
const timeRemaining = getTimeRemaining();
|
46 |
|
47 |
// Create location string by concatenating city and country
|
48 |
const location = [city, country].filter(Boolean).join(", ");
|
|
|
50 |
// Determine countdown color based on days remaining
|
51 |
const getCountdownColor = () => {
|
52 |
if (!deadlineDate || !isValid(deadlineDate)) return "text-neutral-600";
|
53 |
+
try {
|
54 |
+
const daysRemaining = Math.ceil((deadlineDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24));
|
55 |
+
if (daysRemaining <= 7) return "text-red-600";
|
56 |
+
if (daysRemaining <= 30) return "text-orange-600";
|
57 |
+
return "text-green-600";
|
58 |
+
} catch (error) {
|
59 |
+
console.error('Error calculating countdown color:', error);
|
60 |
+
return "text-neutral-600";
|
61 |
+
}
|
62 |
};
|
63 |
|
64 |
const handleCardClick = (e: React.MouseEvent) => {
|
src/components/ConferenceDialog.tsx
CHANGED
@@ -16,6 +16,7 @@ import {
|
|
16 |
DropdownMenuTrigger,
|
17 |
} from "@/components/ui/dropdown-menu";
|
18 |
import { useState, useEffect } from "react";
|
|
|
19 |
|
20 |
interface ConferenceDialogProps {
|
21 |
conference: Conference;
|
@@ -25,7 +26,7 @@ interface ConferenceDialogProps {
|
|
25 |
|
26 |
const ConferenceDialog = ({ conference, open, onOpenChange }: ConferenceDialogProps) => {
|
27 |
console.log('Conference object:', conference);
|
28 |
-
const deadlineDate = conference.deadline
|
29 |
const [countdown, setCountdown] = useState<string>('');
|
30 |
|
31 |
// Replace the current location string creation with this more verbose version
|
@@ -57,8 +58,8 @@ const ConferenceDialog = ({ conference, open, onOpenChange }: ConferenceDialogPr
|
|
57 |
return;
|
58 |
}
|
59 |
|
60 |
-
const now = new Date()
|
61 |
-
const difference = deadlineDate.getTime() - now;
|
62 |
|
63 |
if (difference <= 0) {
|
64 |
setCountdown('Deadline passed');
|
@@ -73,13 +74,8 @@ const ConferenceDialog = ({ conference, open, onOpenChange }: ConferenceDialogPr
|
|
73 |
setCountdown(`${days}d ${hours}h ${minutes}m ${seconds}s`);
|
74 |
};
|
75 |
|
76 |
-
// Calculate immediately
|
77 |
calculateTimeLeft();
|
78 |
-
|
79 |
-
// Update every second
|
80 |
const timer = setInterval(calculateTimeLeft, 1000);
|
81 |
-
|
82 |
-
// Cleanup interval on component unmount
|
83 |
return () => clearInterval(timer);
|
84 |
}, [deadlineDate]);
|
85 |
|
@@ -180,6 +176,22 @@ END:VCALENDAR`;
|
|
180 |
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(venue || place)}`;
|
181 |
};
|
182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
183 |
return (
|
184 |
<Dialog open={open} onOpenChange={onOpenChange}>
|
185 |
<DialogContent
|
@@ -278,11 +290,7 @@ END:VCALENDAR`;
|
|
278 |
<span className={`font-medium ${getCountdownColor()}`}>
|
279 |
{countdown}
|
280 |
</span>
|
281 |
-
{
|
282 |
-
<div className="text-sm text-neutral-500">
|
283 |
-
{format(deadlineDate, "MMMM d, yyyy 'at' HH:mm:ss")} {conference.timezone}
|
284 |
-
</div>
|
285 |
-
)}
|
286 |
</div>
|
287 |
</div>
|
288 |
|
|
|
16 |
DropdownMenuTrigger,
|
17 |
} from "@/components/ui/dropdown-menu";
|
18 |
import { useState, useEffect } from "react";
|
19 |
+
import { getDeadlineInLocalTime } from '@/utils/dateUtils';
|
20 |
|
21 |
interface ConferenceDialogProps {
|
22 |
conference: Conference;
|
|
|
26 |
|
27 |
const ConferenceDialog = ({ conference, open, onOpenChange }: ConferenceDialogProps) => {
|
28 |
console.log('Conference object:', conference);
|
29 |
+
const deadlineDate = getDeadlineInLocalTime(conference.deadline, conference.timezone);
|
30 |
const [countdown, setCountdown] = useState<string>('');
|
31 |
|
32 |
// Replace the current location string creation with this more verbose version
|
|
|
58 |
return;
|
59 |
}
|
60 |
|
61 |
+
const now = new Date();
|
62 |
+
const difference = deadlineDate.getTime() - now.getTime();
|
63 |
|
64 |
if (difference <= 0) {
|
65 |
setCountdown('Deadline passed');
|
|
|
74 |
setCountdown(`${days}d ${hours}h ${minutes}m ${seconds}s`);
|
75 |
};
|
76 |
|
|
|
77 |
calculateTimeLeft();
|
|
|
|
|
78 |
const timer = setInterval(calculateTimeLeft, 1000);
|
|
|
|
|
79 |
return () => clearInterval(timer);
|
80 |
}, [deadlineDate]);
|
81 |
|
|
|
176 |
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(venue || place)}`;
|
177 |
};
|
178 |
|
179 |
+
const formatDeadlineDisplay = () => {
|
180 |
+
if (!deadlineDate || !isValid(deadlineDate)) return null;
|
181 |
+
|
182 |
+
const localTZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
183 |
+
return (
|
184 |
+
<div className="text-sm text-neutral-500">
|
185 |
+
<div>{format(deadlineDate, "MMMM d, yyyy 'at' HH:mm:ss")} ({localTZ})</div>
|
186 |
+
{conference.timezone && conference.timezone !== localTZ && (
|
187 |
+
<div className="text-xs">
|
188 |
+
Conference timezone: {conference.timezone}
|
189 |
+
</div>
|
190 |
+
)}
|
191 |
+
</div>
|
192 |
+
);
|
193 |
+
};
|
194 |
+
|
195 |
return (
|
196 |
<Dialog open={open} onOpenChange={onOpenChange}>
|
197 |
<DialogContent
|
|
|
290 |
<span className={`font-medium ${getCountdownColor()}`}>
|
291 |
{countdown}
|
292 |
</span>
|
293 |
+
{formatDeadlineDisplay()}
|
|
|
|
|
|
|
|
|
294 |
</div>
|
295 |
</div>
|
296 |
|
src/utils/dateUtils.ts
ADDED
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { parseISO, isValid } from 'date-fns';
|
2 |
+
import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
|
3 |
+
|
4 |
+
export const getDeadlineInLocalTime = (deadline: string | undefined, timezone: string | undefined): Date | null => {
|
5 |
+
if (!deadline || deadline === 'TBD') {
|
6 |
+
console.log('Early return - deadline is null or TBD:', { deadline, timezone });
|
7 |
+
return null;
|
8 |
+
}
|
9 |
+
|
10 |
+
try {
|
11 |
+
console.log('Processing conference deadline:', {
|
12 |
+
conferenceName: 'Unknown', // We could pass this as a parameter if helpful
|
13 |
+
deadline,
|
14 |
+
timezone,
|
15 |
+
deadlineType: typeof deadline,
|
16 |
+
timezoneType: typeof timezone
|
17 |
+
});
|
18 |
+
|
19 |
+
// Parse the deadline string to a Date object
|
20 |
+
const deadlineDate = parseISO(deadline);
|
21 |
+
console.log('Parsed deadline date:', {
|
22 |
+
original: deadline,
|
23 |
+
parsed: deadlineDate,
|
24 |
+
isValid: isValid(deadlineDate),
|
25 |
+
timestamp: deadlineDate.getTime(),
|
26 |
+
toISOString: deadlineDate.toISOString()
|
27 |
+
});
|
28 |
+
|
29 |
+
if (!isValid(deadlineDate)) {
|
30 |
+
console.error('Invalid date parsed from deadline:', deadline);
|
31 |
+
return null;
|
32 |
+
}
|
33 |
+
|
34 |
+
// Handle AoE (Anywhere on Earth) timezone
|
35 |
+
if (timezone === 'AoE') {
|
36 |
+
console.log('Converting AoE to UTC-12');
|
37 |
+
return new Date(deadlineDate.getTime() - 12 * 60 * 60 * 1000);
|
38 |
+
}
|
39 |
+
|
40 |
+
// Handle UTC offset timezones (e.g., "UTC-12", "UTC+01", "UTC+0")
|
41 |
+
const normalizeTimezone = (tz: string | undefined): string => {
|
42 |
+
if (!tz) {
|
43 |
+
console.log('No timezone provided, using UTC');
|
44 |
+
return 'UTC';
|
45 |
+
}
|
46 |
+
|
47 |
+
console.log('Normalizing timezone:', tz);
|
48 |
+
|
49 |
+
// If it's already an IANA timezone, return as is
|
50 |
+
if (!tz.toUpperCase().startsWith('UTC')) {
|
51 |
+
console.log('Using IANA timezone:', tz);
|
52 |
+
return tz;
|
53 |
+
}
|
54 |
+
|
55 |
+
// Convert UTC±XX to proper format
|
56 |
+
const match = tz.match(/^UTC([+-])(\d+)$/);
|
57 |
+
if (match) {
|
58 |
+
const [, sign, hours] = match;
|
59 |
+
const paddedHours = hours.padStart(2, '0');
|
60 |
+
const normalized = `${sign}${paddedHours}:00`;
|
61 |
+
console.log('Normalized UTC offset:', { original: tz, normalized });
|
62 |
+
return normalized;
|
63 |
+
}
|
64 |
+
|
65 |
+
// Handle special case of UTC+0/UTC-0
|
66 |
+
if (tz === 'UTC+0' || tz === 'UTC-0' || tz === 'UTC+00' || tz === 'UTC-00') {
|
67 |
+
console.log('Handling UTC+0/-0 case:', tz);
|
68 |
+
return 'UTC';
|
69 |
+
}
|
70 |
+
|
71 |
+
console.log('Falling back to UTC for timezone:', tz);
|
72 |
+
return 'UTC';
|
73 |
+
};
|
74 |
+
|
75 |
+
const normalizedTimezone = normalizeTimezone(timezone);
|
76 |
+
console.log('Using timezone:', { original: timezone, normalized: normalizedTimezone });
|
77 |
+
|
78 |
+
try {
|
79 |
+
// Create date in the conference's timezone
|
80 |
+
const dateInConfTimezone = utcToZonedTime(deadlineDate, normalizedTimezone);
|
81 |
+
console.log('Conference timezone date:', {
|
82 |
+
date: dateInConfTimezone,
|
83 |
+
isValid: isValid(dateInConfTimezone),
|
84 |
+
timezone: normalizedTimezone
|
85 |
+
});
|
86 |
+
|
87 |
+
// Get user's local timezone
|
88 |
+
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
89 |
+
|
90 |
+
// Convert to user's local timezone
|
91 |
+
const localDate = utcToZonedTime(dateInConfTimezone, userTimezone);
|
92 |
+
console.log('Local timezone date:', {
|
93 |
+
date: localDate,
|
94 |
+
isValid: isValid(localDate),
|
95 |
+
timezone: userTimezone
|
96 |
+
});
|
97 |
+
|
98 |
+
if (!isValid(localDate)) {
|
99 |
+
console.error('Invalid date after timezone conversion:', {
|
100 |
+
original: deadline,
|
101 |
+
timezone,
|
102 |
+
normalizedTimezone,
|
103 |
+
localDate
|
104 |
+
});
|
105 |
+
return null;
|
106 |
+
}
|
107 |
+
|
108 |
+
return localDate;
|
109 |
+
} catch (tzError) {
|
110 |
+
console.error('Timezone conversion error:', {
|
111 |
+
error: tzError,
|
112 |
+
deadline,
|
113 |
+
timezone,
|
114 |
+
normalizedTimezone
|
115 |
+
});
|
116 |
+
return deadlineDate;
|
117 |
+
}
|
118 |
+
} catch (error) {
|
119 |
+
console.error('Error parsing deadline:', {
|
120 |
+
error,
|
121 |
+
deadline,
|
122 |
+
timezone
|
123 |
+
});
|
124 |
+
return null;
|
125 |
+
}
|
126 |
+
};
|