FlowWeb / src /features /canvas /components /nodes /HabitNode.jsx
danylokhodus's picture
canban
f104d74
Raw
History Blame Contribute Delete
14 kB
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;