diff --git a/.gitignore b/.gitignore index 6b3700645e..43277dfc82 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,4 @@ figma-images/ # Build cache *.tsbuildinfo -node-compile-cache/ \ No newline at end of file +node-compile-cache/ diff --git a/packages/webapp/components/log/CardWelcome.tsx b/packages/webapp/components/log/CardWelcome.tsx index 649251283a..4103f2af46 100644 --- a/packages/webapp/components/log/CardWelcome.tsx +++ b/packages/webapp/components/log/CardWelcome.tsx @@ -1,9 +1,14 @@ import type { ReactElement } from 'react'; -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { motion } from 'framer-motion'; import { ArrowIcon } from '@dailydotdev/shared/src/components/icons'; +import { UploadIcon } from '@dailydotdev/shared/src/components/icons/Upload'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { useAuthContext } from '@dailydotdev/shared/src/contexts/AuthContext'; +import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; +import { useLogDataOverride } from '../../contexts/LogDataOverrideContext'; +import { validateLogData } from '../../lib/validateLogData'; +import type { LogData } from '../../types/log'; import styles from './Log.module.css'; import type { BaseCardProps } from './types'; @@ -20,6 +25,10 @@ export default function CardWelcome({ }: BaseCardProps): ReactElement { const { user } = useAuthContext(); const [isMounted, setIsMounted] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + const { displayToast } = useToastNotification(); + const { setOverrideData } = useLogDataOverride(); useEffect(() => { setIsMounted(true); @@ -28,6 +37,67 @@ export default function CardWelcome({ // Get the user's first name (or username as fallback) const displayName = user?.name?.split(' ')[0] || user?.username || 'Dev'; + // Handle file upload + const handleFileUpload = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + // Check file type + if (file.type !== 'application/json') { + displayToast('Please upload a valid JSON file'); + return; + } + + setIsUploading(true); + + try { + // Read file contents + const text = await file.text(); + const data = JSON.parse(text); + + // Validate the data structure + const validation = validateLogData(data); + if (!validation.valid) { + displayToast(`Invalid log data format: ${validation.errors[0]}`); + return; + } + + // Store the data in memory + setOverrideData(data as LogData); + displayToast('Log data uploaded successfully!'); + } catch (error) { + if (error instanceof SyntaxError) { + displayToast('Invalid JSON file format'); + } else { + displayToast('Failed to upload log data'); + } + } finally { + setIsUploading(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, + [displayToast, setOverrideData], + ); + + const handleUploadClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + // Prevent click from bubbling up to parent's tap navigation + const handleButtonClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + handleUploadClick(); + }, + [handleUploadClick], + ); + // Before mount, hide elements that should animate in const hidden = { opacity: 0, y: 20 }; const visible = { opacity: 1, y: 0 }; @@ -114,13 +184,15 @@ export default function CardWelcome({
- Preparing your year in review... + {isUploading + ? 'Processing your log data...' + : 'Preparing your year in review...'}
@@ -128,10 +200,12 @@ export default function CardWelcome({ @@ -140,6 +214,57 @@ export default function CardWelcome({ + + {/* Upload option - appears after CTA (fade only) */} + + + +
); } diff --git a/packages/webapp/contexts/LogDataOverrideContext.tsx b/packages/webapp/contexts/LogDataOverrideContext.tsx new file mode 100644 index 0000000000..bbf0ac328d --- /dev/null +++ b/packages/webapp/contexts/LogDataOverrideContext.tsx @@ -0,0 +1,49 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback } from 'react'; +import type { LogData } from '../types/log'; + +interface LogDataOverrideContextValue { + overrideData: LogData | null; + setOverrideData: (data: LogData | null) => void; +} + +const LogDataOverrideContext = createContext< + LogDataOverrideContextValue | undefined +>(undefined); + +interface LogDataOverrideProviderProps { + children: ReactNode; +} + +export function LogDataOverrideProvider({ + children, +}: LogDataOverrideProviderProps): ReactElement { + const [overrideData, setOverrideDataState] = useState(null); + + const setOverrideData = useCallback((data: LogData | null) => { + setOverrideDataState(data); + }, []); + + return ( + + {children} + + ); +} + +export function useLogDataOverride(): LogDataOverrideContextValue { + const context = useContext(LogDataOverrideContext); + + // Return default values for SSR/when provider is not available + // File upload only happens on client, so this is safe + if (context === undefined) { + return { + overrideData: null, + setOverrideData: () => { + // No-op during SSR + }, + }; + } + + return context; +} diff --git a/packages/webapp/hooks/log/useLog.ts b/packages/webapp/hooks/log/useLog.ts index 1c148bcc22..3cbaf89e89 100644 --- a/packages/webapp/hooks/log/useLog.ts +++ b/packages/webapp/hooks/log/useLog.ts @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { apiUrl } from '@dailydotdev/shared/src/lib/config'; import type { LogData } from '../../types/log'; +import { useLogDataOverride } from '../../contexts/LogDataOverrideContext'; /** * Query key for log data @@ -60,15 +61,17 @@ export interface UseLogResult { /** * Hook for fetching log data from the API. * Returns hasData: false when the user doesn't have enough 2025 activity. + * If override data is provided (via uploaded JSON), it takes precedence over API data. */ export function useLog(options: UseLogOptions | boolean = true): UseLogResult { + const { overrideData } = useLogDataOverride(); const { enabled = true, userId } = typeof options === 'boolean' ? { enabled: options } : options; const query = useQuery({ queryKey: userId ? [...LOG_QUERY_KEY, userId] : LOG_QUERY_KEY, queryFn: () => fetchLog(userId), - enabled, + enabled: enabled && !overrideData, // Don't fetch from API if we have override data staleTime: Infinity, // Log data doesn't change often retry: (failureCount, error) => { // Don't retry on 404 (no data available) @@ -79,6 +82,16 @@ export function useLog(options: UseLogOptions | boolean = true): UseLogResult { }, }); + // If we have override data, use it instead of API data + if (overrideData) { + return { + data: overrideData, + isLoading: false, + hasData: true, + error: null, + }; + } + // Determine if user has data based on whether we got a NoLogDataError const hasNoDataError = query.error instanceof NoLogDataError; const hasData = !hasNoDataError && !query.error; diff --git a/packages/webapp/lib/validateLogData.ts b/packages/webapp/lib/validateLogData.ts new file mode 100644 index 0000000000..ae69434629 --- /dev/null +++ b/packages/webapp/lib/validateLogData.ts @@ -0,0 +1,200 @@ +import type { LogData, Archetype } from '../types/log'; +import { ARCHETYPES } from '../types/log'; + +/** + * Validation result for log data + */ +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +/** + * Check if a value is a valid archetype + */ +function isValidArchetype(value: unknown): value is Archetype { + return ( + typeof value === 'string' && + Object.keys(ARCHETYPES).includes(value as Archetype) + ); +} + +/** + * Check if a value is a valid reading pattern + */ +function isValidReadingPattern( + value: unknown, +): value is 'night' | 'early' | 'afternoon' { + return ( + typeof value === 'string' && ['night', 'early', 'afternoon'].includes(value) + ); +} + +/** + * Validate that the uploaded JSON matches the LogData structure + */ +export function validateLogData(data: unknown): ValidationResult { + const errors: string[] = []; + + // Must be an object + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + return { + valid: false, + errors: ['Data must be a JSON object'], + }; + } + + const logData = data as Partial; + + // Required fields for Card 1: Total Impact + if (typeof logData.totalPosts !== 'number' || logData.totalPosts < 0) { + errors.push('totalPosts must be a non-negative number'); + } + if ( + typeof logData.totalReadingTime !== 'number' || + logData.totalReadingTime < 0 + ) { + errors.push('totalReadingTime must be a non-negative number'); + } + if (typeof logData.daysActive !== 'number' || logData.daysActive < 0) { + errors.push('daysActive must be a non-negative number'); + } + if ( + typeof logData.totalImpactPercentile !== 'number' || + logData.totalImpactPercentile < 0 || + logData.totalImpactPercentile > 100 + ) { + errors.push('totalImpactPercentile must be a number between 0 and 100'); + } + + // Required fields for Card 2: When You Read + if (typeof logData.peakDay !== 'string' || !logData.peakDay) { + errors.push('peakDay must be a non-empty string (e.g., "Thursday")'); + } + if (!isValidReadingPattern(logData.readingPattern)) { + errors.push( + 'readingPattern must be one of: "night", "early", or "afternoon"', + ); + } + if ( + typeof logData.patternPercentile !== 'number' || + logData.patternPercentile < 0 || + logData.patternPercentile > 100 + ) { + errors.push('patternPercentile must be a number between 0 and 100'); + } + if (!Array.isArray(logData.activityHeatmap)) { + errors.push('activityHeatmap must be an array'); + } else if (logData.activityHeatmap.length !== 7) { + errors.push('activityHeatmap must have 7 rows (days)'); + } else { + logData.activityHeatmap.forEach((row, i) => { + if (!Array.isArray(row) || row.length !== 24) { + errors.push(`activityHeatmap row ${i} must have 24 hours`); + } + }); + } + + // Required fields for Card 3: Topic Evolution + if (!Array.isArray(logData.topicJourney)) { + errors.push('topicJourney must be an array'); + } + if (typeof logData.uniqueTopics !== 'number' || logData.uniqueTopics < 0) { + errors.push('uniqueTopics must be a non-negative number'); + } + if ( + typeof logData.evolutionPercentile !== 'number' || + logData.evolutionPercentile < 0 || + logData.evolutionPercentile > 100 + ) { + errors.push('evolutionPercentile must be a number between 0 and 100'); + } + + // Required fields for Card 4: Favorite Sources + if (!Array.isArray(logData.topSources) || logData.topSources.length !== 3) { + errors.push('topSources must be an array with exactly 3 sources'); + } else { + logData.topSources.forEach((source, i) => { + if (typeof source !== 'object' || source === null) { + errors.push(`topSources[${i}] must be an object`); + } else { + if (typeof source.name !== 'string') { + errors.push(`topSources[${i}].name must be a string`); + } + if (typeof source.postsRead !== 'number' || source.postsRead < 0) { + errors.push( + `topSources[${i}].postsRead must be a non-negative number`, + ); + } + if (typeof source.logoUrl !== 'string') { + errors.push(`topSources[${i}].logoUrl must be a string`); + } + } + }); + } + if (typeof logData.uniqueSources !== 'number' || logData.uniqueSources < 0) { + errors.push('uniqueSources must be a non-negative number'); + } + if ( + typeof logData.sourcePercentile !== 'number' || + logData.sourcePercentile < 0 || + logData.sourcePercentile > 100 + ) { + errors.push('sourcePercentile must be a number between 0 and 100'); + } + if ( + typeof logData.sourceLoyaltyName !== 'string' || + !logData.sourceLoyaltyName + ) { + errors.push('sourceLoyaltyName must be a non-empty string'); + } + + // Required fields for Card 5: Community Engagement + if (typeof logData.upvotesGiven !== 'number' || logData.upvotesGiven < 0) { + errors.push('upvotesGiven must be a non-negative number'); + } + if ( + typeof logData.commentsWritten !== 'number' || + logData.commentsWritten < 0 + ) { + errors.push('commentsWritten must be a non-negative number'); + } + if ( + typeof logData.postsBookmarked !== 'number' || + logData.postsBookmarked < 0 + ) { + errors.push('postsBookmarked must be a non-negative number'); + } + + // Optional fields for Card 6: Contributions + if (typeof logData.hasContributions !== 'boolean') { + errors.push('hasContributions must be a boolean'); + } + + // Required fields for Card 7: Records + if (!Array.isArray(logData.records)) { + errors.push('records must be an array'); + } + + // Required fields for Card 8: Archetype + if (!isValidArchetype(logData.archetype)) { + errors.push( + `archetype must be one of: ${Object.keys(ARCHETYPES).join(', ')}`, + ); + } + if (typeof logData.archetypeStat !== 'string' || !logData.archetypeStat) { + errors.push('archetypeStat must be a non-empty string'); + } + if ( + typeof logData.archetypePercentile !== 'number' || + logData.archetypePercentile < 0 || + logData.archetypePercentile > 100 + ) { + errors.push('archetypePercentile must be a number between 0 and 100'); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/packages/webapp/pages/log/index.tsx b/packages/webapp/pages/log/index.tsx index 0fd3a9f4f1..fa86e568da 100644 --- a/packages/webapp/pages/log/index.tsx +++ b/packages/webapp/pages/log/index.tsx @@ -1,5 +1,4 @@ import type { ReactElement } from 'react'; -import type { GetServerSideProps } from 'next'; import React, { useMemo, useEffect, @@ -16,6 +15,10 @@ import { useImagePreloader } from '@dailydotdev/shared/src/hooks/useImagePreload import { useToastNotification } from '@dailydotdev/shared/src/hooks/useToastNotification'; import Toast from '@dailydotdev/shared/src/components/notifications/Toast'; import ProtectedPage from '../../components/ProtectedPage'; +import { + LogDataOverrideProvider, + useLogDataOverride, +} from '../../contexts/LogDataOverrideContext'; import { ARCHETYPES } from '../../types/log'; import { useCardNavigation, @@ -45,12 +48,6 @@ import CardArchetypeReveal from '../../components/log/CardArchetypeReveal'; import CardShare from '../../components/log/CardShare'; import CardNoData from '../../components/log/CardNoData'; -export const getServerSideProps: GetServerSideProps = async () => { - return { - notFound: true, - }; -}; - // Default theme for no-data state (welcome card theme) const noDataTheme = CARD_THEMES.welcome; @@ -73,6 +70,9 @@ export default function LogPage(): ReactElement { userId: typeof userId === 'string' ? userId : undefined, }); + // Track if we're using override data to force card re-renders when data source changes + const { overrideData } = useLogDataOverride(); + // Preload images (archetypes + source logos) during browser idle time const imagesToPreload = useMemo(() => { const archetypeUrls = Object.values(ARCHETYPES).map((a) => a.imageUrl); @@ -271,93 +271,95 @@ export default function LogPage(): ReactElement { const showNoDataCard = !isLoading && !hasData; return ( - - - - - - - {/* Only show header with progress bars when user has data */} - {!showNoDataCard && ( - - )} - - {showNoDataCard ? ( - /* No data card - single card, no navigation */ -
-
-
- + + + + + + + + {/* Only show header with progress bars when user has data */} + {!showNoDataCard && ( + + )} + + {showNoDataCard ? ( + /* No data card - single card, no navigation */ +
+
+
+ +
-
- ) : ( - /* Cards with AnimatePresence - tap to navigate */ -
{ - if (e.key === 'Enter' || e.key === ' ') { - goNext(); - } - }} - > - { + if (e.key === 'Enter' || e.key === ' ') { + goNext(); + } + }} > - -
- -
-
-
-
- )} - - - + +
+ +
+
+ +
+ )} + + + + ); }