Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from 'react'; | |
| import { Handle, Position, useReactFlow, useUpdateNodeInternals } from '@xyflow/react'; | |
| import { Timer, Settings, Check, RefreshCw, Flame, Sparkles, AlertCircle } from 'lucide-react'; | |
| import { useTranslation } from 'react-i18next'; | |
| import { isDefaultLabel } from '../../utils'; | |
| const HabitNode = ({ id, data, selected }) => { | |
| const { t } = useTranslation(); | |
| const { setNodes, setEdges } = useReactFlow(); | |
| const updateNodeInternals = useUpdateNodeInternals(); | |
| const [countdownText, setCountdownText] = useState(''); | |
| // Re-calculate handles when node size might change | |
| useEffect(() => { | |
| updateNodeInternals(id); | |
| }, [id, data.label, data.description, data.resetRule, updateNodeInternals]); | |
| // Downstream chain reset logic (BFS) | |
| const triggerManualReset = (e) => { | |
| e.stopPropagation(); | |
| const event = new CustomEvent('trigger-habit-reset', { | |
| detail: { nodeId: id } | |
| }); | |
| window.dispatchEvent(event); | |
| if (data.updateNodeData) { | |
| data.updateNodeData(id, { | |
| status: 'Todo', | |
| lastResetTime: new Date().toISOString() | |
| }); | |
| } else { | |
| setNodes((currentNodes) => { | |
| return currentNodes.map(node => { | |
| if (node.id === id) { | |
| return { | |
| ...node, | |
| data: { | |
| ...node.data, | |
| status: 'Todo', | |
| lastResetTime: new Date().toISOString() | |
| } | |
| }; | |
| } | |
| return node; | |
| }); | |
| }); | |
| } | |
| }; | |
| const toggleDone = (e) => { | |
| e.stopPropagation(); | |
| const newStatus = data.status === 'Done' ? 'Todo' : 'Done'; | |
| if (data.updateNodeData) { | |
| data.updateNodeData(id, { status: newStatus }); | |
| } else { | |
| setNodes((nds) => | |
| nds.map((node) => | |
| node.id === id ? { ...node, data: { ...node.data, status: newStatus } } : node | |
| ) | |
| ); | |
| } | |
| }; | |
| const handleConfigure = (e) => { | |
| e.stopPropagation(); | |
| const event = new CustomEvent('configure-habit-node', { | |
| detail: { nodeId: id, type: 'habit' } | |
| }); | |
| window.dispatchEvent(event); | |
| }; | |
| // Helper to format reset rule | |
| const getResetRuleText = () => { | |
| const rule = data.resetRule; | |
| if (!rule) return t('canvas.habit_rule_daily', { time: '00:00' }); | |
| if (rule.type === 'daily') { | |
| return t('canvas.habit_rule_daily', { time: rule.time || '00:00' }); | |
| } | |
| if (rule.type === 'weekly') { | |
| const daysKeys = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']; | |
| const dayKey = daysKeys[rule.day !== undefined ? Number(rule.day) : 1]; | |
| const dayName = t(`common.days_short.${dayKey}`); | |
| return t('canvas.habit_rule_weekly', { day: dayName, time: rule.time || '00:00' }); | |
| } | |
| if (rule.type === 'hourly') { | |
| const totalHours = Number(rule.hours || 24); | |
| const hours = Math.floor(totalHours); | |
| const minutes = Math.round((totalHours - hours) * 60); | |
| if (hours > 0 && minutes > 0) { | |
| return t('canvas.habit_rule_mixed', { hours, minutes }); | |
| } | |
| if (hours % 24 === 0) { | |
| return t('canvas.habit_rule_days', { days: hours / 24 }); | |
| } | |
| return t('canvas.habit_rule_hourly', { hours }); | |
| } | |
| if (rule.type === 'minutely') { | |
| const totalMinutes = Number(rule.minutes || 5); | |
| const hours = Math.floor(totalMinutes / 60); | |
| const minutes = totalMinutes % 60; | |
| if (hours > 0 && minutes > 0) { | |
| return t('canvas.habit_rule_mixed', { hours, minutes }); | |
| } | |
| if (hours > 0 && minutes === 0) { | |
| if (hours % 24 === 0) { | |
| return t('canvas.habit_rule_days', { days: hours / 24 }); | |
| } | |
| return t('canvas.habit_rule_hourly', { hours }); | |
| } | |
| return t('canvas.habit_rule_minutely', { minutes }); | |
| } | |
| return t('canvas.habit_rule_daily', { time: '00:00' }); | |
| }; | |
| // Calculate next reset countdown text | |
| useEffect(() => { | |
| const updateCountdown = () => { | |
| const lastResetTime = data.lastResetTime || data.createdAt || new Date().toISOString(); | |
| const rule = data.resetRule || { type: 'daily', time: '00:00' }; | |
| const lastReset = new Date(lastResetTime); | |
| const now = new Date(); | |
| let nextReset; | |
| if (rule.type === 'daily') { | |
| const [hour, minute] = (rule.time || '00:00').split(':').map(Number); | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(hour, minute, 0, 0); | |
| if (lastReset.getTime() < rDate.getTime()) { | |
| nextReset = rDate; | |
| } else { | |
| nextReset = new Date(rDate); | |
| nextReset.setDate(nextReset.getDate() + 1); | |
| } | |
| } else if (rule.type === 'weekly') { | |
| const dayOfWeek = rule.day !== undefined ? Number(rule.day) : 1; | |
| const [hour, minute] = (rule.time || '00:00').split(':').map(Number); | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(hour, minute, 0, 0); | |
| nextReset = rDate; | |
| while (nextReset.getDay() !== dayOfWeek || nextReset.getTime() <= lastReset.getTime()) { | |
| nextReset.setDate(nextReset.setDate() + 1); | |
| } | |
| } else if (rule.type === 'hourly') { | |
| const hours = Number(rule.hours || 24); | |
| nextReset = new Date(lastReset.getTime() + hours * 60 * 60 * 1000); | |
| } else if (rule.type === 'minutely') { | |
| const minutes = Number(rule.minutes || 5); | |
| nextReset = new Date(lastReset.getTime() + minutes * 60 * 1000); | |
| } else { | |
| const rDate = new Date(lastReset); | |
| rDate.setHours(0, 0, 0, 0); | |
| if (lastReset.getTime() < rDate.getTime()) { | |
| nextReset = rDate; | |
| } else { | |
| nextReset = new Date(rDate); | |
| nextReset.setDate(nextReset.getDate() + 1); | |
| } | |
| } | |
| const diffMs = nextReset.getTime() - now.getTime(); | |
| if (diffMs <= 0) { | |
| setCountdownText(t('canvas.habit_resetting')); | |
| return; | |
| } | |
| const diffHours = Math.floor(diffMs / (60 * 60 * 1000)); | |
| const diffMins = Math.floor((diffMs % (60 * 60 * 1000)) / (60 * 1000)); | |
| const diffSecs = Math.floor((diffMs % (60 * 1000)) / 1000); | |
| if (diffHours > 0) { | |
| setCountdownText(`${diffHours}${t('common.hours_short')} ${diffMins}${t('common.minutes_short')}`); | |
| } else { | |
| setCountdownText(`${diffMins}${t('common.minutes_short')} ${diffSecs}${t('common.seconds_short')}`); | |
| } | |
| }; | |
| updateCountdown(); | |
| const rule = data.resetRule || { type: 'daily', time: '00:00' }; | |
| const intervalTime = (rule.type === 'minutely' || rule.type === 'hourly') ? 1000 : 60000; | |
| const interval = setInterval(updateCountdown, intervalTime); | |
| return () => clearInterval(interval); | |
| }, [data.lastResetTime, data.createdAt, data.resetRule, t]); | |
| const isDone = data.status === 'Done'; | |
| return ( | |
| <div | |
| onDoubleClick={handleConfigure} | |
| className={`px-5 py-4 w-64 rough-border bg-surface-container-lowest transition-all group ${ | |
| selected | |
| ? 'shadow-[6px_6px_0px_0px_rgba(52,211,153,0.3)] dark:shadow-[6px_6px_0px_0px_rgba(52,211,153,0.3)] -translate-y-1 border-emerald-400 ring-4 ring-emerald-400/20 scale-[1.01]' | |
| : (isDone ? 'shadow-[0_0_12px_rgba(34,197,94,0.3)] dark:shadow-[0_0_15px_rgba(34,197,94,0.25)] ring-2 ring-green-500/40' : '') | |
| } ${isDone ? 'opacity-95' : ''} ${ | |
| data.isNextStep ? 'ring-4 ring-success/30 ring-offset-2 ring-offset-background shadow-[0_0_20px_rgba(34,197,94,0.2)]' : '' | |
| }`} | |
| > | |
| {/* Input Target Handle */} | |
| <Handle | |
| type="target" | |
| position={Position.Top} | |
| id="main-target" | |
| className="!bg-surface-container-lowest !w-4 !h-4 !border-[3px] !border-primary shadow-[0_2px_4px_rgba(0,0,0,0.1)] hover:scale-120 transition-all !z-50 !absolute" | |
| style={{ | |
| left: '50%', | |
| top: '0px', | |
| bottom: 'auto', | |
| right: 'auto', | |
| transform: 'translate(-50%, -50%)' | |
| }} | |
| /> | |
| <div className="flex flex-col gap-3 pointer-events-none"> | |
| {/* Header with Title and Settings */} | |
| <div className="flex justify-between items-start gap-2 pointer-events-none"> | |
| <div className="flex items-center gap-2 pointer-events-none"> | |
| <div className="p-1.5 bg-secondary/10 border border-primary/20 rounded-lg pointer-events-none"> | |
| <Timer size={18} className="text-secondary animate-pulse pointer-events-none" /> | |
| </div> | |
| <span className="font-display-lg text-lg text-primary leading-tight break-words max-w-[150px] pointer-events-none"> | |
| {isDefaultLabel(data.label) ? t('canvas.new_habit', 'New Habit') : data.label} | |
| </span> | |
| </div> | |
| <button | |
| onClick={handleConfigure} | |
| className="p-1 hover:bg-surface-variant text-primary/60 hover:text-primary border border-transparent hover:border-primary/20 rounded-md transition-all cursor-pointer pointer-events-auto" | |
| > | |
| <Settings size={15} /> | |
| </button> | |
| </div> | |
| {/* Description if present */} | |
| {data.description && ( | |
| <p className="text-xs font-accent-note text-on-surface-variant italic leading-normal -mt-1 pointer-events-none"> | |
| {data.description} | |
| </p> | |
| )} | |
| {/* Status Badge & Actions */} | |
| <div className="flex items-center justify-between py-1 border-t border-b border-primary/10 pointer-events-none"> | |
| <div className="flex flex-col gap-0.5 pointer-events-none"> | |
| <span className="text-[8px] font-bold uppercase tracking-widest text-primary/50 pointer-events-none">{t('canvas.habit_status')}</span> | |
| <span className={`text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-sm border pointer-events-none ${ | |
| isDone | |
| ? 'bg-green-500/10 text-success border-success/30' | |
| : 'bg-amber-500/10 text-[#f59e0b] border-[#f59e0b]/30' | |
| }`}> | |
| {isDone ? t('canvas.habit_status_done', 'Done') : t('canvas.habit_status_todo', 'Todo')} | |
| </span> | |
| </div> | |
| {/* Toggle Done Button */} | |
| <button | |
| onClick={toggleDone} | |
| className={`flex items-center gap-1.5 px-3 py-1.5 font-bold text-xs border-2 border-primary rounded-lg transition-all cursor-pointer shadow-[2px_2px_0px_0px_var(--color-primary)] active:translate-y-0.5 active:shadow-[0px_0px_0px_0px_var(--color-primary)] pointer-events-auto ${ | |
| isDone | |
| ? 'bg-green-500 hover:bg-green-600 text-white' | |
| : 'bg-surface-container-lowest hover:bg-surface-variant text-primary' | |
| }`} | |
| > | |
| {isDone ? <Check size={13} strokeWidth={3} /> : <div className="w-2.5 h-2.5 rounded-full border-2 border-primary" />} | |
| <span>{isDone ? t('canvas.habit_action_done') : t('canvas.habit_action_todo')}</span> | |
| </button> | |
| </div> | |
| {/* Time-Reset Countdown Block */} | |
| <div className="flex items-center justify-between text-xs bg-secondary/5 rounded-xl px-3 py-2 border border-primary/10 pointer-events-none"> | |
| <div className="flex flex-col gap-0.5 pointer-events-none"> | |
| <span className="text-[8px] font-bold uppercase tracking-widest text-primary/50 pointer-events-none">{t('canvas.habit_reset_rule')}</span> | |
| <span className="font-bold text-[10px] text-primary leading-tight pointer-events-none">{getResetRuleText()}</span> | |
| </div> | |
| <div className="flex flex-col items-end gap-0.5 pointer-events-none"> | |
| <span className="text-[8px] font-bold uppercase tracking-widest text-primary/50 pointer-events-none">{t('canvas.habit_countdown_label')}</span> | |
| <span className="font-bold text-[10px] text-secondary font-mono pointer-events-none">{countdownText}</span> | |
| </div> | |
| </div> | |
| {/* Signal state & Test buttons */} | |
| <div className="flex items-center justify-between text-xs -mt-1 pointer-events-none"> | |
| {/* Signal Indicator */} | |
| {!isDone ? ( | |
| <div className="flex items-center gap-1 text-[#f59e0b] font-bold text-[9px] animate-pulse pointer-events-none"> | |
| <Sparkles size={11} className="fill-[#f59e0b]/20 pointer-events-none" /> | |
| <span className="pointer-events-none">{t('canvas.habit_signal_active')}</span> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-1 text-primary/40 font-bold text-[9px] pointer-events-none"> | |
| <span className="pointer-events-none">{t('canvas.habit_signal_inactive')}</span> | |
| </div> | |
| )} | |
| {/* Test Manual Reset Button */} | |
| <button | |
| onClick={triggerManualReset} | |
| title={t('canvas.habit_test_reset_tooltip')} | |
| className="flex items-center gap-1 px-2 py-1 text-[9px] font-bold uppercase tracking-wider bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/30 rounded-md transition-all cursor-pointer pointer-events-auto" | |
| > | |
| <RefreshCw size={10} className="animate-spin-slow pointer-events-none" /> | |
| <span className="pointer-events-none">{t('canvas.habit_test_reset')}</span> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Output Source Handle */} | |
| <Handle | |
| type="source" | |
| position={Position.Bottom} | |
| className="!bg-transparent !w-8 !h-8 !border-0 hover:scale-110 transition-all !z-[9999] !absolute cursor-crosshair flex items-center justify-center" | |
| style={{ | |
| left: '50%', | |
| top: '100%', | |
| bottom: 'auto', | |
| right: 'auto', | |
| transform: 'translate(-50%, -50%)' | |
| }} | |
| > | |
| <div className="w-3 h-3 rounded-full bg-primary border-2 border-white shadow-sm pointer-events-none" /> | |
| </Handle> | |
| </div> | |
| ); | |
| }; | |
| export default HabitNode; | |