Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,4 @@ figma-images/

# Build cache
*.tsbuildinfo
node-compile-cache/
node-compile-cache/
139 changes: 132 additions & 7 deletions packages/webapp/components/log/CardWelcome.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<HTMLInputElement>(null);
const { displayToast } = useToastNotification();
const { setOverrideData } = useLogDataOverride();

useEffect(() => {
setIsMounted(true);
Expand All @@ -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<HTMLInputElement>) => {
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 };
Expand Down Expand Up @@ -114,24 +184,28 @@ export default function CardWelcome({
<div
className={styles.loadingState}
style={{
opacity: isLoading ? 1 : 0,
pointerEvents: isLoading ? 'auto' : 'none',
opacity: isLoading || isUploading ? 1 : 0,
pointerEvents: isLoading || isUploading ? 'auto' : 'none',
}}
>
<div className={styles.loadingSpinner} />
<span className={styles.loadingText}>
Preparing your year in review...
{isUploading
? 'Processing your log data...'
: 'Preparing your year in review...'}
</span>
</div>

{/* Ready state */}
<motion.div
className={styles.readyState}
style={{
opacity: isLoading ? 0 : 1,
pointerEvents: isLoading ? 'none' : 'auto',
opacity: isLoading || isUploading ? 0 : 1,
pointerEvents: isLoading || isUploading ? 'none' : 'auto',
}}
animate={!isLoading && isMounted ? { x: [0, 10, 0] } : {}}
animate={
!isLoading && !isUploading && isMounted ? { x: [0, 10, 0] } : {}
}
transition={{ repeat: Infinity, duration: 1.5, ease: 'easeInOut' }}
>
<span className={styles.ctaText}>
Expand All @@ -140,6 +214,57 @@ export default function CardWelcome({
</span>
</motion.div>
</motion.div>

{/* Upload option - appears after CTA (fade only) */}
<motion.div
className={styles.welcomeUpload}
initial={{ opacity: 0 }}
animate={{ opacity: isMounted && !isLoading ? 1 : 0 }}
transition={{ delay: CTA_DELAY + 0.5, duration: 0.5 }}
style={{
marginTop: '1.5rem',
textAlign: 'center',
pointerEvents: isLoading || isUploading ? 'none' : 'auto',
}}
>
<input
ref={fileInputRef}
type="file"
accept="application/json,.json"
onChange={handleFileUpload}
style={{ display: 'none' }}
/>
<button
onClick={handleButtonClick}
className={styles.uploadButton}
type="button"
disabled={isLoading || isUploading}
style={{
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
padding: '0.75rem 1.25rem',
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '0.875rem',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.4)';
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.9)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.2)';
e.currentTarget.style.color = 'rgba(255, 255, 255, 0.7)';
}}
>
<UploadIcon size={IconSize.XSmall} />
Upload custom log data (JSON)
</button>
</motion.div>
</div>
);
}
49 changes: 49 additions & 0 deletions packages/webapp/contexts/LogDataOverrideContext.tsx
Original file line number Diff line number Diff line change
@@ -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<LogData | null>(null);

const setOverrideData = useCallback((data: LogData | null) => {
setOverrideDataState(data);
}, []);

return (
<LogDataOverrideContext.Provider value={{ overrideData, setOverrideData }}>
{children}
</LogDataOverrideContext.Provider>
);
}

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;
}
15 changes: 14 additions & 1 deletion packages/webapp/hooks/log/useLog.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<LogData, Error>({
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)
Expand All @@ -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;
Expand Down
Loading