From 33abad7a01e98b599612eabcc71f4d6029b043c6 Mon Sep 17 00:00:00 2001 From: sabarish Date: Tue, 21 Oct 2025 01:40:23 +0530 Subject: [PATCH] Feat : Add tooltips to CO2 factors in ActivityForm #19 --- src/components/forms/ActivityForm.tsx | 194 +++++++++++++++++--------- src/components/ui/TooltTip.tsx | 33 ++--- src/constants/co2Factors.ts | 63 +++++---- 3 files changed, 183 insertions(+), 107 deletions(-) diff --git a/src/components/forms/ActivityForm.tsx b/src/components/forms/ActivityForm.tsx index 48684d4..d0101e1 100644 --- a/src/components/forms/ActivityForm.tsx +++ b/src/components/forms/ActivityForm.tsx @@ -1,9 +1,16 @@ -'use client'; +"use client"; -import { useState } from 'react'; -import { ActivityInput } from '@/types'; -import { calculateCarbonFootprint } from '@/lib/calculations/carbonFootprint'; -import { ACTIVITY_LABELS, ACTIVITY_DESCRIPTIONS } from '@/constants/co2Factors'; +import { useState } from "react"; +import { ActivityInput } from "@/types"; +import { calculateCarbonFootprint } from "@/lib/calculations/carbonFootprint"; +import { + ACTIVITY_LABELS, + ACTIVITY_DESCRIPTIONS, + CO2_FACTORS, + FIELD_TO_CO2_KEY, +} from "@/constants/co2Factors"; +import Tooltip from "../ui/TooltTip"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; interface ActivityFormProps { onSubmit: ( @@ -18,7 +25,10 @@ interface ActivityFormProps { initialValues?: Partial; } -export default function ActivityForm({ onSubmit, initialValues }: ActivityFormProps) { +export default function ActivityForm({ + onSubmit, + initialValues, +}: ActivityFormProps) { const [activities, setActivities] = useState({ emails: initialValues?.emails || 0, streamingHours: initialValues?.streamingHours || 0, @@ -35,20 +45,20 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr const validateField = (field: keyof ActivityInput, value: number) => { if (value <= 0) { - if (field.includes('Hours')) { - return 'Duration must be greater than 0'; - } else if (field === 'emails' || field === 'cloudStorageGB') { - return 'Quantity must be greater than 0'; + if (field.includes("Hours")) { + return "Duration must be greater than 0"; + } else if (field === "emails" || field === "cloudStorageGB") { + return "Quantity must be greater than 0"; } } - return ''; + return ""; }; const validateForm = () => { const newErrors: Record = {}; let hasActivity = false; - formFields.forEach(field => { + formFields.forEach((field) => { const value = activities[field.key]; if (value > 0) { hasActivity = true; @@ -63,7 +73,7 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr }); if (!hasActivity) { - newErrors.form = 'Please select at least one activity'; + newErrors.form = "Please select at least one activity"; } setErrors(newErrors); @@ -71,41 +81,44 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr }; const handleInputChange = (field: keyof ActivityInput, value: number) => { - setActivities(prev => ({ ...prev, [field]: Math.max(0, value) })); + setActivities((prev) => ({ ...prev, [field]: Math.max(0, value) })); // Clear error when user starts typing + if (errors.form) { + setErrors((prev) => ({ ...prev, form: "" })); + } if (errors[field]) { - setErrors(prev => ({ ...prev, [field]: '' })); + setErrors((prev) => ({ ...prev, [field]: "" })); } }; const handleBlur = (field: keyof ActivityInput) => { - setTouched(prev => ({ ...prev, [field]: true })); + setTouched((prev) => ({ ...prev, [field]: true })); const error = validateField(field, activities[field]); - setErrors(prev => ({ ...prev, [field]: error })); + setErrors((prev) => ({ ...prev, [field]: error })); }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - + // Mark all fields as touched const allTouched: Record = {}; - formFields.forEach(field => { + formFields.forEach((field) => { allTouched[field.key] = true; }); setTouched(allTouched); - + if (!validateForm()) { return; // Don't submit if invalid } - + setIsSubmitting(true); try { const result = calculateCarbonFootprint(activities); - + // Start submission immediately - don't wait for completion onSubmit(activities, result); - + // Reset form after a short delay setTimeout(() => { setActivities({ @@ -121,69 +134,68 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr setTouched({}); setIsSubmitting(false); }, 500); - } catch (error) { - console.error('Error submitting activities:', error); + console.error("Error submitting activities:", error); setIsSubmitting(false); } }; const formFields = [ { - key: 'emails' as keyof ActivityInput, + key: "emails" as keyof ActivityInput, label: ACTIVITY_LABELS.emails, description: ACTIVITY_DESCRIPTIONS.emails, max: 500, step: 1, - icon: '📧', + icon: "📧", }, { - key: 'streamingHours' as keyof ActivityInput, + key: "streamingHours" as keyof ActivityInput, label: ACTIVITY_LABELS.streaming, description: ACTIVITY_DESCRIPTIONS.streaming, max: 24, step: 0.5, - icon: '📺', + icon: "📺", }, { - key: 'codingHours' as keyof ActivityInput, + key: "codingHours" as keyof ActivityInput, label: ACTIVITY_LABELS.coding, description: ACTIVITY_DESCRIPTIONS.coding, max: 24, step: 0.5, - icon: '💻', + icon: "💻", }, { - key: 'videoCallHours' as keyof ActivityInput, + key: "videoCallHours" as keyof ActivityInput, label: ACTIVITY_LABELS.video_calls, description: ACTIVITY_DESCRIPTIONS.video_calls, max: 24, step: 0.5, - icon: '📹', + icon: "📹", }, { - key: 'cloudStorageGB' as keyof ActivityInput, + key: "cloudStorageGB" as keyof ActivityInput, label: ACTIVITY_LABELS.cloud_storage, description: ACTIVITY_DESCRIPTIONS.cloud_storage, max: 1000, step: 1, - icon: '☁️', + icon: "☁️", }, { - key: 'gamingHours' as keyof ActivityInput, + key: "gamingHours" as keyof ActivityInput, label: ACTIVITY_LABELS.gaming, description: ACTIVITY_DESCRIPTIONS.gaming, max: 24, step: 0.5, - icon: '🎮', + icon: "🎮", }, { - key: 'socialMediaHours' as keyof ActivityInput, + key: "socialMediaHours" as keyof ActivityInput, label: ACTIVITY_LABELS.social_media, description: ACTIVITY_DESCRIPTIONS.social_media, max: 24, step: 0.5, - icon: '📱', + icon: "📱", }, ]; @@ -195,7 +207,8 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr Track Your Daily Digital Activities

- Enter your digital activities for today to calculate your carbon footprint + Enter your digital activities for today to calculate your carbon + footprint

@@ -208,8 +221,31 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr + {`${field.label} Produces ${ + CO2_FACTORS[FIELD_TO_CO2_KEY[field.key]] + } Grams of CO2/${ + field.key.includes("Hours") + ? "Hr" + : field.key.includes("GB") + ? "GB" + : field.key === "emails" + ? "Email" + : "Unit" + }`} + } + > + + - +

{field.description}

@@ -222,10 +258,12 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr max={field.max} step={field.step} value={activities[field.key]} - onChange={(e) => handleInputChange(field.key, parseFloat(e.target.value))} + onChange={(e) => + handleInputChange(field.key, parseFloat(e.target.value)) + } onBlur={() => handleBlur(field.key)} className={`flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider ${ - errors[field.key] ? 'border border-red-500' : '' + errors[field.key] ? "border border-red-500" : "" }`} aria-invalid={!!errors[field.key]} aria-describedby={`${field.key}-error ${field.key}-hint`} @@ -237,46 +275,69 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr max={field.max} step={field.step} value={activities[field.key]} - onChange={(e) => handleInputChange(field.key, parseFloat(e.target.value) || 0)} + onChange={(e) => + handleInputChange( + field.key, + parseFloat(e.target.value) || 0 + ) + } onBlur={() => handleBlur(field.key)} className={`w-20 px-3 py-2 border rounded-md focus:outline-none focus:ring-2 text-gray-900 ${ - errors[field.key] - ? 'border-red-500 focus:ring-red-500' - : 'border-gray-300 focus:ring-green-500' + errors[field.key] + ? "border-red-500 focus:ring-red-500" + : "border-gray-300 focus:ring-green-500" }`} aria-invalid={!!errors[field.key]} aria-describedby={`${field.key}-error ${field.key}-hint`} /> - {field.key.includes('Hours') ? 'hrs' : - field.key.includes('GB') ? 'GB' : - field.key === 'emails' ? 'emails' : 'units'} + {field.key.includes("Hours") + ? "hrs" + : field.key.includes("GB") + ? "GB" + : field.key === "emails" + ? "emails" + : "units"} - + {/* Progress bar */}
{/* Hint */} -

- {field.key.includes('Hours') - ? `Enter duration in ${field.key.includes('streaming') || field.key.includes('gaming') ? 'hours' : 'hours'} (e.g., 2.5)` - : field.key === 'emails' - ? 'Enter number of emails sent today' - : field.key === 'cloudStorageGB' - ? 'Enter storage used in GB' - : 'Enter quantity'} +

+ {field.key.includes("Hours") + ? `Enter duration in ${ + field.key.includes("streaming") || + field.key.includes("gaming") + ? "hours" + : "hours" + } (e.g., 2.5)` + : field.key === "emails" + ? "Enter number of emails sent today" + : field.key === "cloudStorageGB" + ? "Enter storage used in GB" + : "Enter quantity"}

{/* Error message */} {errors[field.key] && ( - )} @@ -297,7 +358,12 @@ export default function ActivityForm({ onSubmit, initialValues }: ActivityFormPr
); -} \ No newline at end of file +} diff --git a/src/components/ui/TooltTip.tsx b/src/components/ui/TooltTip.tsx index e348a1e..7e00d0b 100644 --- a/src/components/ui/TooltTip.tsx +++ b/src/components/ui/TooltTip.tsx @@ -56,7 +56,8 @@ export default function Tooltip({ >(undefined); const [mounted, setMounted] = useState(false); const hideTimer = useRef(null); - const rafId = useRef(null); + const rafTooltipId = useRef(null); + const rafIconId = useRef(null); // Track screen width to apply mobile behavior (< sm = 640px) const [isSmallScreen, setIsSmallScreen] = useState(false); @@ -116,9 +117,9 @@ export default function Tooltip({ if (!visible) return; // Use rAF to avoid layout thrash when many scroll/resize events fire const schedule = () => { - if (rafId.current != null) return; - rafId.current = window.requestAnimationFrame(() => { - rafId.current = null; + if (rafTooltipId.current != null) return; + rafTooltipId.current = window.requestAnimationFrame(() => { + rafTooltipId.current = null; compute(); }); }; @@ -260,7 +261,6 @@ export default function Tooltip({ // Observe tooltip and target size changes const ro = new ResizeObserver(() => schedule()); if (targetRef.current) ro.observe(targetRef.current); - if (mobileIconRef.current) ro.observe(mobileIconRef.current); if (tooltipRef.current) ro.observe(tooltipRef.current); // Recompute on any scroll (capture true to catch nested scroll containers) @@ -270,9 +270,9 @@ export default function Tooltip({ ro.disconnect(); window.removeEventListener("scroll", schedule, true); window.removeEventListener("resize", schedule); - if (rafId.current != null) { - window.cancelAnimationFrame(rafId.current); - rafId.current = null; + if (rafTooltipId.current != null) { + window.cancelAnimationFrame(rafTooltipId.current); + rafTooltipId.current = null; } }; }, [visible, placement, offset, isSmallScreen, mobilePlacement]); @@ -286,9 +286,9 @@ export default function Tooltip({ const ICON_SIZE = 24; // px (w-6 h-6) const schedule = () => { - if (rafId.current != null) return; - rafId.current = window.requestAnimationFrame(() => { - rafId.current = null; + if (rafIconId.current != null) return; + rafIconId.current = window.requestAnimationFrame(() => { + rafIconId.current = null; computeIcon(); }); }; @@ -318,11 +318,6 @@ export default function Tooltip({ left = r.right - ICON_SIZE - 4; break; } - // Keep inside viewport just in case - const vw = window.innerWidth; - const vh = window.innerHeight; - left = Math.min(Math.max(left, 4), vw - ICON_SIZE - 4); - top = Math.min(Math.max(top, 4), vh - ICON_SIZE - 4); setMobileIconStyle({ position: "fixed", top: Math.round(top), @@ -340,9 +335,9 @@ export default function Tooltip({ ro.disconnect(); window.removeEventListener("scroll", schedule, true); window.removeEventListener("resize", schedule); - if (rafId.current != null) { - window.cancelAnimationFrame(rafId.current); - rafId.current = null; + if (rafIconId.current != null) { + window.cancelAnimationFrame(rafIconId.current); + rafIconId.current = null; } }; }, [isSmallScreen, mobileShowInfoIcon, mobileIconPlacement]); diff --git a/src/constants/co2Factors.ts b/src/constants/co2Factors.ts index 439cd47..89a0f70 100644 --- a/src/constants/co2Factors.ts +++ b/src/constants/co2Factors.ts @@ -1,4 +1,4 @@ -import { ActivityType } from '@/types'; +import { ActivityInput, ActivityType } from "@/types"; // CO2 emissions in grams per unit export const CO2_FACTORS: Record = { @@ -11,48 +11,63 @@ export const CO2_FACTORS: Record = { social_media: 12, // grams per hour }; +// converts the field in activity input to the corresponding key in CO2_FACTORS +// thus when modifying the co2Factors and field modify this helper too +export const FIELD_TO_CO2_KEY: Record< + keyof ActivityInput, + keyof typeof CO2_FACTORS +> = { + emails: "emails", + streamingHours: "streaming", + codingHours: "coding", + videoCallHours: "video_calls", + cloudStorageGB: "cloud_storage", + gamingHours: "gaming", + socialMediaHours: "social_media", +}; + // Real-world equivalents for context export const EQUIVALENTS = [ { - unit: 'km_driving', + unit: "km_driving", factor: 120, // grams CO2 per km - description: 'driving {value} km in a car', + description: "driving {value} km in a car", }, { - unit: 'phone_charges', + unit: "phone_charges", factor: 8.22, // grams CO2 per charge - description: 'charging your phone {value} times', + description: "charging your phone {value} times", }, { - unit: 'tea_cups', + unit: "tea_cups", factor: 21, // grams CO2 per cup - description: 'boiling {value} cups of tea', + description: "boiling {value} cups of tea", }, { - unit: 'light_bulb_hours', + unit: "light_bulb_hours", factor: 50, // grams CO2 per hour (60W bulb) - description: 'running a light bulb for {value} hours', + description: "running a light bulb for {value} hours", }, ]; // Activity labels for UI export const ACTIVITY_LABELS: Record = { - emails: 'Emails Sent', - streaming: 'Video Streaming', - coding: 'Coding/Development', - video_calls: 'Video Calls', - cloud_storage: 'Cloud Storage', - gaming: 'Gaming', - social_media: 'Social Media', + emails: "Emails Sent", + streaming: "Video Streaming", + coding: "Coding/Development", + video_calls: "Video Calls", + cloud_storage: "Cloud Storage", + gaming: "Gaming", + social_media: "Social Media", }; // Activity descriptions export const ACTIVITY_DESCRIPTIONS: Record = { - emails: 'Number of emails sent today', - streaming: 'Hours spent watching videos (Netflix, YouTube, etc.)', - coding: 'Hours spent coding or using development tools', - video_calls: 'Hours in video meetings (Zoom, Meet, Teams)', - cloud_storage: 'Gigabytes of cloud storage used', - gaming: 'Hours spent gaming online', - social_media: 'Hours browsing social media platforms', -}; \ No newline at end of file + emails: "Number of emails sent today", + streaming: "Hours spent watching videos (Netflix, YouTube, etc.)", + coding: "Hours spent coding or using development tools", + video_calls: "Hours in video meetings (Zoom, Meet, Teams)", + cloud_storage: "Gigabytes of cloud storage used", + gaming: "Hours spent gaming online", + social_media: "Hours browsing social media platforms", +};