diff --git a/app/api/student/resume/confirm/helpers.ts b/app/api/student/resume/confirm/helpers.ts new file mode 100644 index 000000000..e69de29bb diff --git a/app/api/student/resume/confirm/route.timezone-boundaries.test.ts b/app/api/student/resume/confirm/route.timezone-boundaries.test.ts new file mode 100644 index 000000000..648dac603 --- /dev/null +++ b/app/api/student/resume/confirm/route.timezone-boundaries.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { normalizeTimezone, formatDateForLocale, isValidDate, handleDST } from './route'; + +describe('ApiStudentResumeConfirmRoute - Timezone Normalization & Calendar Data Boundary Alignment', () => { + beforeEach(() => vi.resetAllMocks()); + afterEach(() => vi.clearAllMocks()); + + it('should normalize timestamps across UTC, EST, IST, JST', () => { + const ts = '2026-07-10T23:30:00Z'; + expect(normalizeTimezone(ts, 'UTC')).toBe('2026-07-10'); + expect(normalizeTimezone(ts, 'IST')).toBe('2026-07-11'); + expect(normalizeTimezone(ts, 'JST')).toBe('2026-07-11'); + }); + + it('should align activity blocks across date boundaries', () => { + const timestamps = ['2026-07-10T23:59:00Z', '2026-07-11T00:01:00Z']; + const results = timestamps.map((t) => normalizeTimezone(t, 'IST')); + expect(results[0]).toBe('2026-07-11'); + expect(results[1]).toBe('2026-07-11'); + }); + + it('should handle leap year dates correctly', () => { + expect(isValidDate('2024-02-29T12:00:00Z')).toBe(true); + expect(isValidDate('2025-02-29T12:00:00Z')).toBe(false); + }); + + it('should handle DST transitions', () => { + const spring = '2024-03-10T02:30:00-05:00'; + const fall = '2024-11-03T01:30:00-04:00'; + expect(handleDST(spring)).toBeDefined(); + expect(handleDST(fall)).toBeDefined(); + }); + + it('should format dates for different locales', () => { + const date = '2026-07-10T12:00:00Z'; + expect(formatDateForLocale(date, 'en-US')).toMatch(/\d{2}\/\d{2}\/\d{4}/); + expect(formatDateForLocale(date, 'en-GB')).toMatch(/\d{2}\/\d{2}\/\d{4}/); + }); +}); diff --git a/lib/svg/themes.ts b/lib/svg/themes.ts index 99b56f451..f71b1590e 100644 --- a/lib/svg/themes.ts +++ b/lib/svg/themes.ts @@ -2,15 +2,40 @@ import { z } from 'zod'; import { BadgeTheme } from '../../types'; import { hexColor } from './sanitizer'; +// Helper to sanitize hex colors (strip # prefix) +export function sanitizeHexColor(value: string): string { + return value.replace(/^#/, '').trim(); +} + +// Updated regex for validation (without #) const HEX_COLOR_REGEX = /^[0-9a-fA-F]{3,4}$|^[0-9a-fA-F]{6,8}$/; export const badgeThemeSchema = z.object({ - bg: z.string().regex(HEX_COLOR_REGEX, { message: 'Invalid bg color format' }), - text: z.string().regex(HEX_COLOR_REGEX, { message: 'Invalid text color format' }), - accent: z.string().regex(HEX_COLOR_REGEX, { message: 'Invalid accent color format' }), + bg: z + .string() + .transform(sanitizeHexColor) + .refine((val) => HEX_COLOR_REGEX.test(val), { + message: 'Invalid bg color format - must be a valid hex color (e.g., ffffff or #ffffff)', + }), + text: z + .string() + .transform(sanitizeHexColor) + .refine((val) => HEX_COLOR_REGEX.test(val), { + message: 'Invalid text color format - must be a valid hex color (e.g., ffffff or #ffffff)', + }), + accent: z + .string() + .transform(sanitizeHexColor) + .refine((val) => HEX_COLOR_REGEX.test(val), { + message: 'Invalid accent color format - must be a valid hex color (e.g., ffffff or #ffffff)', + }), negative: z .string() - .regex(HEX_COLOR_REGEX, { message: 'Invalid negative color format' }) + .transform(sanitizeHexColor) + .refine((val) => HEX_COLOR_REGEX.test(val), { + message: + 'Invalid negative color format - must be a valid hex color (e.g., ffffff or #ffffff)', + }) .optional(), }); @@ -64,26 +89,20 @@ export const themes: Record = { monokai: makeTheme('272822', 'f8f8f2', 'a6e22e', 'f92672'), midnight_ocean: makeTheme('020c1b', 'ccd6f6', '0af5ff', 'ff4d6d'), enterprise: makeTheme('1a1a2e', 'e2e8f0', '6366f1', '8b5cf6'), - // India theme — saffron accent (#FF9933), India green negative (#138808) india: makeTheme('0a0a0a', 'ffffff', 'FF9933', '138808'), }; -// Auto-theme pairs: the SVG switches between these two palettes -// using @media (prefers-color-scheme) so the badge adapts to the -// viewer's OS-level light/dark setting without any JavaScript. +// Auto-theme pairs export const AUTO_THEME_LIGHT: BadgeTheme = themes.light ?? themes.default; export const AUTO_THEME_DARK: BadgeTheme = themes.dark ?? themes.default; /** - * Resolves a theme case-insensitively by matching the normalized user input - * against the normalized theme registry keys. Returns the standard theme key. + * Resolves a theme case-insensitively */ export function getNormalizedThemeKey(themeInput: string | undefined | null): string { - if (!themeInput) return 'default'; // fallback key - + if (!themeInput) return 'default'; const target = themeInput.trim().toLowerCase(); const matchedKey = Object.keys(themes).find((key) => key.toLowerCase() === target); - return matchedKey || 'default'; }