diff --git a/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts b/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts new file mode 100644 index 0000000000..35a8072107 --- /dev/null +++ b/__tests__/components/meme-calendar/meme-calendar.helpers.timezone.test.ts @@ -0,0 +1,114 @@ +import { + mintEndInstantUtcForMintDay, + mintStartInstantUtcForMintDay, + nextMintDateOnOrAfter, + wallTimeToUtcInstantInZone, +} from "@/components/meme-calendar/meme-calendar.helpers"; + +const formatAthensTime = (date: Date): string => + new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Athens", + hour12: false, + hour: "2-digit", + minute: "2-digit", + }).format(date); + +const isoDate = (y: number, m: number, d: number): Date => + new Date(Date.UTC(y, m, d)); + +describe("meme calendar timezone handling", () => { + it("keeps mint start anchored to 17:40 Athens time across 2024", () => { + const months = Array.from({ length: 12 }, (_, idx) => idx); + + for (const month of months) { + const mintDay = nextMintDateOnOrAfter(isoDate(2024, month, 1)); + const mintStart = mintStartInstantUtcForMintDay(mintDay); + const mintEnd = mintEndInstantUtcForMintDay(mintDay); + + expect(formatAthensTime(mintStart)).toBe("17:40"); + expect(formatAthensTime(mintEnd)).toBe("17:00"); + expect(mintEnd.getTime()).toBeGreaterThan(mintStart.getTime()); + } + }); + + it("applies the spring DST shift only after the EU transition completes", () => { + const beforeShiftMint = nextMintDateOnOrAfter(isoDate(2024, 2, 10)); // Mon, 11 Mar 2024 (still winter offset) + expect(beforeShiftMint.toISOString()).toBe("2024-03-11T00:00:00.000Z"); + + const beforeShiftStart = mintStartInstantUtcForMintDay(beforeShiftMint); + const beforeShiftEnd = mintEndInstantUtcForMintDay(beforeShiftMint); + + expect(beforeShiftStart.toISOString()).toBe("2024-03-11T15:40:00.000Z"); + expect(beforeShiftEnd.toISOString()).toBe("2024-03-12T15:00:00.000Z"); + expect(formatAthensTime(beforeShiftStart)).toBe("17:40"); + expect(formatAthensTime(beforeShiftEnd)).toBe("17:00"); + + const afterShiftMint = nextMintDateOnOrAfter(isoDate(2024, 3, 1)); // Mon, 1 Apr 2024 + expect(afterShiftMint.toISOString()).toBe("2024-04-01T00:00:00.000Z"); + + const afterShiftStart = mintStartInstantUtcForMintDay(afterShiftMint); + const afterShiftEnd = mintEndInstantUtcForMintDay(afterShiftMint); + + expect(afterShiftStart.toISOString()).toBe("2024-04-01T14:40:00.000Z"); + expect(afterShiftEnd.toISOString()).toBe("2024-04-02T14:00:00.000Z"); + expect(formatAthensTime(afterShiftStart)).toBe("17:40"); + expect(formatAthensTime(afterShiftEnd)).toBe("17:00"); + }); + + it("handles the autumn DST rollback without breaking mint anchors", () => { + const beforeRollbackMint = nextMintDateOnOrAfter(isoDate(2024, 9, 24)); // Fri, 25 Oct 2024 + expect(beforeRollbackMint.toISOString()).toBe("2024-10-25T00:00:00.000Z"); + + const beforeRollbackStart = mintStartInstantUtcForMintDay(beforeRollbackMint); + const beforeRollbackEnd = mintEndInstantUtcForMintDay(beforeRollbackMint); + + expect(beforeRollbackStart.toISOString()).toBe("2024-10-25T14:40:00.000Z"); + expect(beforeRollbackEnd.toISOString()).toBe("2024-10-26T14:00:00.000Z"); + expect(formatAthensTime(beforeRollbackStart)).toBe("17:40"); + expect(formatAthensTime(beforeRollbackEnd)).toBe("17:00"); + + const afterRollbackMint = nextMintDateOnOrAfter(isoDate(2024, 9, 27)); // Mon, 28 Oct 2024 + expect(afterRollbackMint.toISOString()).toBe("2024-10-28T00:00:00.000Z"); + + const afterRollbackStart = mintStartInstantUtcForMintDay(afterRollbackMint); + const afterRollbackEnd = mintEndInstantUtcForMintDay(afterRollbackMint); + + expect(afterRollbackStart.toISOString()).toBe("2024-10-28T15:40:00.000Z"); + expect(afterRollbackEnd.toISOString()).toBe("2024-10-29T15:00:00.000Z"); + expect(formatAthensTime(afterRollbackStart)).toBe("17:40"); + expect(formatAthensTime(afterRollbackEnd)).toBe("17:00"); + }); + + it("shares phase wall-clock logic via wallTimeToUtcInstantInZone", () => { + const summerMintDay = isoDate(2024, 6, 1); + const winterMintDay = isoDate(2024, 0, 3); + + const phaseWallTimes: Array<[number, number]> = [ + [17, 40], + [18, 20], + [18, 30], + [18, 50], + [19, 0], + [19, 20], + ]; + + const getPhaseUtcInstants = (mintDay: Date) => + phaseWallTimes.map(([hour, minute]) => + wallTimeToUtcInstantInZone(mintDay, hour, minute) + ); + + const summerPhaseTimes = getPhaseUtcInstants(summerMintDay); + const winterPhaseTimes = getPhaseUtcInstants(winterMintDay); + + const expectedWallTimes = phaseWallTimes.map( + ([hour, minute]) => + `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}` + ); + + expect(summerPhaseTimes.map(formatAthensTime)).toEqual(expectedWallTimes); + expect(winterPhaseTimes.map(formatAthensTime)).toEqual(expectedWallTimes); + + expect(summerPhaseTimes[0].toISOString()).toBe("2024-07-01T14:40:00.000Z"); + expect(winterPhaseTimes[0].toISOString()).toBe("2024-01-03T15:40:00.000Z"); + }); +}); diff --git a/components/meme-calendar/meme-calendar.helpers.tsx b/components/meme-calendar/meme-calendar.helpers.tsx index f261e869bd..1d95db07e6 100644 --- a/components/meme-calendar/meme-calendar.helpers.tsx +++ b/components/meme-calendar/meme-calendar.helpers.tsx @@ -17,7 +17,7 @@ export const SZN1_RANGE = { } as const; export const SZN1_SEASON_INDEX = -13; -const EUROPE_TZ = "Europe/Nicosia"; // EET/EEST +const EUROPE_TZ = "Europe/Athens"; // EET/EEST const EUROPE_WINTER_OFFSET_HOURS = +2; // EET (UTC+2) const EUROPE_SUMMER_OFFSET_HOURS = +3; // EEST (UTC+3) const MINT_EUROPE_HOUR = 17; diff --git a/hooks/useManifoldClaim.ts b/hooks/useManifoldClaim.ts index c26849e8d5..7a2dfb47fc 100644 --- a/hooks/useManifoldClaim.ts +++ b/hooks/useManifoldClaim.ts @@ -25,6 +25,20 @@ export enum ManifoldPhase { PUBLIC = "Public Phase", } +interface PhaseBoundary { + hour: number; + minute: number; + dayOffset?: number; +} + +interface PhaseDefinition { + id: string; + name: string; + type: ManifoldPhase; + start: PhaseBoundary; + end: PhaseBoundary; +} + export interface MemePhase { id: string; name: string; @@ -33,52 +47,59 @@ export interface MemePhase { end: Time; } -export function buildMemesPhases(mintDate: Time): MemePhase[] { - const buildTime = ( - hour: number, - minute: number, - nextDay: boolean = false - ) => { - const ref = nextDay ? mintDate.plusDays(1) : mintDate; - return Time.fromString( - wallTimeToUtcInstantInZone(ref.toDate(), hour, minute).toISOString() +const PHASE_DEFINITIONS: PhaseDefinition[] = [ + { + id: "0", + name: "Phase 0 (Allowlist)", + type: ManifoldPhase.ALLOWLIST, + start: { hour: 17, minute: 40 }, + end: { hour: 18, minute: 20 }, + }, + { + id: "1", + name: "Phase 1 (Allowlist)", + type: ManifoldPhase.ALLOWLIST, + start: { hour: 18, minute: 30 }, + end: { hour: 18, minute: 50 }, + }, + { + id: "2", + name: "Phase 2 (Allowlist)", + type: ManifoldPhase.ALLOWLIST, + start: { hour: 19, minute: 0 }, + end: { hour: 19, minute: 20 }, + }, + { + id: "public", + name: "Public Phase", + type: ManifoldPhase.PUBLIC, + start: { hour: 19, minute: 20 }, + end: { hour: 17, minute: 0, dayOffset: 1 }, + }, +]; + +export function buildMemesPhases(mintDate: Time = Time.now()): MemePhase[] { + const resolveTime = ({ + hour, + minute, + dayOffset = 0, + }: PhaseBoundary): Time => { + const reference = dayOffset === 0 ? mintDate : mintDate.plusDays(dayOffset); + const instant = wallTimeToUtcInstantInZone( + reference.toDate(), + hour, + minute ); + return Time.millis(instant.getTime()); }; - return [ - { - id: "0", - name: "Phase 0 (Allowlist)", - type: ManifoldPhase.ALLOWLIST, - start: buildTime(17, 40), - end: buildTime(18, 20), - }, - { - id: "1", - name: "Phase 1 (Allowlist)", - type: ManifoldPhase.ALLOWLIST, - start: buildTime(18, 30), - end: buildTime(18, 50), - }, - { - id: "2", - name: "Phase 2 (Allowlist)", - type: ManifoldPhase.ALLOWLIST, - start: buildTime(19, 0), - end: buildTime(19, 20), - }, - { - id: "public", - name: "Public Phase", - type: ManifoldPhase.PUBLIC, - start: buildTime(19, 20), - end: buildTime(17, 0, true), - }, - ]; + return PHASE_DEFINITIONS.map(({ start, end, ...phase }) => ({ + ...phase, + start: resolveTime(start), + end: resolveTime(end), + })); } -const MEME_PHASES = buildMemesPhases(Time.now()); - export interface ManifoldClaim { instanceId: number; total: number; @@ -106,7 +127,7 @@ export function useManifoldClaim( const [refetchInterval, setRefetchInterval] = useState(5000); const getStatus = useCallback((start: number, end: number) => { - const now = Date.now() / 1000; + const now = Time.now().toSeconds(); if (now < start) { return ManifoldClaimStatus.UPCOMING; } else if (now >= start && now < end) { @@ -116,17 +137,19 @@ export function useManifoldClaim( }, []); const getMemePhase = useCallback( - (phase: ManifoldPhase, end: number) => { + (phase: ManifoldPhase, start: number, end: number) => { if (!areEqualAddresses(contract, MEMES_CONTRACT)) { return undefined; } + const memePhases = buildMemesPhases(Time.seconds(start)); + if (phase === ManifoldPhase.PUBLIC) { - return MEME_PHASES.find((mp) => mp.id === "public"); + return memePhases.find((mp) => mp.id === "public"); } const endTime = Time.seconds(end); - return MEME_PHASES.find((mp) => mp.end.gte(endTime)); + return memePhases.find((mp) => mp.end.gte(endTime)); }, [contract] ); @@ -155,7 +178,11 @@ export function useManifoldClaim( publicMerkle && claimData.total > 0 ? ManifoldPhase.PUBLIC : ManifoldPhase.ALLOWLIST; - const memePhase = getMemePhase(phase, claimData.endDate); + const memePhase = getMemePhase( + phase, + claimData.startDate, + claimData.endDate + ); const remaining = Number(claimData.totalMax) - Number(claimData.total); const newClaim: ManifoldClaim = { instanceId: instanceId, diff --git a/tsconfig.json b/tsconfig.json index 1a4a9792d6..eedcfeb16f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,18 +14,14 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "types": [ - "@testing-library/jest-dom" - ], + "types": ["@testing-library/jest-dom"], "plugins": [ { "name": "next" } ], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -39,11 +31,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules", - "tests", - "e2e", - "**/__tests__/**", - "**/__mocks__/**" - ] + "exclude": ["node_modules", "tests", "e2e", "**/__mocks__/**"] }