first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
drjones
2026-02-16 21:05:27 -08:00
commit c373bbdab4
25 changed files with 2964 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

224
App.tsx Normal file
View File

@@ -0,0 +1,224 @@
import React, { useState, useEffect } from 'react';
import { UserRole, CalendarEvent, AppSettings, UserStatus } from './types';
import { LoginScreen } from './components/LoginScreen';
import { CalendarView } from './components/CalendarView';
import { EventModal } from './components/EventModal';
import { SettingsModal } from './components/SettingsModal';
import { AssistantView } from './components/AssistantView';
import { SpaceBackground } from './components/SpaceBackground';
import { FairyDust } from './components/FairyDust';
import { getStoredEvents, saveEvent, seedInitialData, deleteEvent, getSettings, saveSettings } from './services/storageService';
import { mcpService } from './services/mcpService';
import { generateDailyHoroscope } from './services/aiService';
import { parseISO, set } from 'date-fns';
const App: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [currentUser, setCurrentUser] = useState<UserRole | null>(null);
const [currentStatus, setCurrentStatus] = useState<UserStatus>('online');
const [events, setEvents] = useState<CalendarEvent[]>([]);
const [settings, setSettings] = useState<AppSettings>(getSettings());
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isAssistantOpen, setIsAssistantOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
// Theme Injection
useEffect(() => {
const root = document.documentElement;
root.style.setProperty('--mom-primary', settings.momColor);
root.style.setProperty('--dad-primary', settings.dadColor);
root.style.setProperty('--mom-soft', `${settings.momColor}33`);
root.style.setProperty('--dad-soft', `${settings.dadColor}33`);
}, [settings]);
useEffect(() => {
seedInitialData();
setEvents(getStoredEvents());
const storedUser = localStorage.getItem('ourtime_user');
if (storedUser) {
setCurrentUser(storedUser as UserRole);
setIsAuthenticated(true);
}
}, []);
// Daily Horoscope Check
useEffect(() => {
const checkHoroscope = async () => {
if (!isAuthenticated || !currentUser || settings.momZodiac === 'Unknown' || settings.dadZodiac === 'Unknown') return;
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
const horoscopeId = `horoscope-${todayStr}`;
// Check if we already have a magic event for today with this ID
const hasHoroscope = events.some(e => e.id === horoscopeId);
if (!hasHoroscope) {
// Generate it
console.log("Generating daily horoscope...");
const prediction = await generateDailyHoroscope(settings.momZodiac, settings.dadZodiac, settings);
const newEvent: CalendarEvent = {
id: horoscopeId,
title: `${prediction}`,
description: `Daily guidance for ${settings.momZodiac} & ${settings.dadZodiac}`,
startTime: new Date().toISOString(), // Use current time/date
endTime: new Date().toISOString(),
isAllDay: true,
category: 'magic',
createdBy: currentUser
};
handleSaveEvent(newEvent);
}
};
// Slight delay to ensure load
const timer = setTimeout(checkHoroscope, 2000);
return () => clearTimeout(timer);
}, [isAuthenticated, currentUser, events, settings]);
// MCP Service Connection
useEffect(() => {
if (settings.enableMcp && settings.mcpServerUrl) {
mcpService.connect(settings.mcpServerUrl);
const unsubscribe = mcpService.subscribe((newEvent) => {
handleSaveEvent(newEvent);
});
return () => {
unsubscribe();
mcpService.disconnect();
};
} else {
mcpService.disconnect();
}
}, [settings.enableMcp, settings.mcpServerUrl]);
const handleLogin = (role: UserRole) => {
setCurrentUser(role);
setIsAuthenticated(true);
localStorage.setItem('ourtime_user', role);
};
const handleLogout = () => {
setCurrentUser(null);
setIsAuthenticated(false);
localStorage.removeItem('ourtime_user');
};
const handleAddEventClick = (date?: Date) => {
setSelectedEvent(null);
setSelectedDate(date);
setIsModalOpen(true);
};
const handleEditEventClick = (event: CalendarEvent) => {
setSelectedEvent(event);
setSelectedDate(parseISO(event.startTime));
setIsModalOpen(true);
};
const handleSaveEvent = (event: CalendarEvent) => {
const updatedEvents = saveEvent(event);
setEvents(updatedEvents);
};
const handleMoveEvent = (eventId: string, newDate: Date) => {
const event = events.find(e => e.id === eventId);
if (!event) return;
const oldStart = parseISO(event.startTime);
const oldEnd = parseISO(event.endTime);
const duration = oldEnd.getTime() - oldStart.getTime();
// Create new start time with the new date but keeping the old hours/minutes
const newStart = set(newDate, {
hours: oldStart.getHours(),
minutes: oldStart.getMinutes(),
seconds: oldStart.getSeconds()
});
const newEnd = new Date(newStart.getTime() + duration);
const updatedEvent = {
...event,
startTime: newStart.toISOString(),
endTime: newEnd.toISOString()
};
handleSaveEvent(updatedEvent);
};
const handleDeleteEvent = (id: string) => {
const updatedEvents = deleteEvent(id);
setEvents(updatedEvents);
};
const handleSaveSettings = (newSettings: AppSettings) => {
setSettings(newSettings);
saveSettings(newSettings);
};
if (!isAuthenticated || !currentUser) {
return (
<>
<SpaceBackground />
<FairyDust />
<LoginScreen onLogin={handleLogin} />
</>
);
}
return (
<div className="min-h-screen text-gray-200 font-sans selection:bg-purple-500 selection:text-white">
<SpaceBackground />
<FairyDust />
<CalendarView
events={events}
currentUser={currentUser}
currentStatus={currentStatus}
onStatusChange={setCurrentStatus}
onAddEvent={handleAddEventClick}
onEditEvent={handleEditEventClick}
onMoveEvent={handleMoveEvent}
onLogout={handleLogout}
onDeleteEvent={handleDeleteEvent}
onOpenSettings={() => setIsSettingsOpen(true)}
onOpenAssistant={() => setIsAssistantOpen(true)}
settings={settings}
/>
<EventModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleSaveEvent}
onDelete={handleDeleteEvent}
currentUserRole={currentUser}
selectedDate={selectedDate}
initialEvent={selectedEvent}
/>
<SettingsModal
isOpen={isSettingsOpen}
onClose={() => setIsSettingsOpen(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<AssistantView
isOpen={isAssistantOpen}
onClose={() => setIsAssistantOpen(false)}
currentUser={currentUser}
settings={settings}
onEventCreated={handleSaveEvent}
/>
</div>
);
};
export default App;

20
README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1WLlHxe0E0cYe_x-n91TIMaSrbIH8tP7F
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -0,0 +1,187 @@
import React, { useState, useRef, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Send, Bot, User as UserIcon, X, Loader2 } from 'lucide-react';
import { ChatMessage, UserRole, AppSettings, CalendarEvent } from '../types';
import { generateChatResponse } from '../services/aiService';
interface AssistantViewProps {
isOpen: boolean;
onClose: () => void;
currentUser: UserRole;
settings: AppSettings;
onEventCreated: (event: CalendarEvent) => void;
}
export const AssistantView: React.FC<AssistantViewProps> = ({
isOpen,
onClose,
currentUser,
settings,
onEventCreated
}) => {
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([
{
id: 'welcome',
role: 'assistant',
content: `Hi ${currentUser === 'mom' ? 'Mom' : 'Dad'}! I am your cosmic assistant. How can I help you plan?`,
timestamp: new Date()
}
]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages, isOpen]);
const handleSend = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!input.trim() || isLoading) return;
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date()
};
setMessages(prev => [...prev, userMsg]);
setInput('');
setIsLoading(true);
const { text, event } = await generateChatResponse(input, settings, currentUser);
const assistantMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: text,
timestamp: new Date()
};
setMessages(prev => [...prev, assistantMsg]);
setIsLoading(false);
if (event) {
onEventCreated(event);
}
};
return (
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
/>
{/* Side Panel */}
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="fixed inset-y-0 right-0 w-full md:w-[450px] bg-gray-900 border-l border-gray-800 shadow-2xl z-50 flex flex-col"
>
{/* Header */}
<div className="p-4 border-b border-gray-800 flex justify-between items-center bg-gray-900/95 backdrop-blur">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-tr from-purple-600 to-blue-600 flex items-center justify-center shadow-lg shadow-purple-900/50">
<Bot className="text-white" size={24} />
</div>
<div>
<h2 className="text-lg font-bold text-white">Home Assistant</h2>
<div className="flex items-center gap-1.5">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
<span className="text-xs text-gray-400 font-mono">
{settings.aiProvider === 'ollama' ? 'Local Spirit' : 'Gemini Cloud'}
</span>
</div>
</div>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-800 rounded-full text-gray-400 hover:text-white transition-colors">
<X size={24} />
</button>
</div>
{/* Chat Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
{messages.map((msg) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
key={msg.id}
className={`flex gap-3 ${msg.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}
>
<div className={`
w-8 h-8 rounded-full flex items-center justify-center shrink-0
${msg.role === 'user'
? (currentUser === 'mom' ? 'bg-mom-primary' : 'bg-dad-primary')
: 'bg-gray-700'}
`}>
{msg.role === 'user' ? <UserIcon size={14} className="text-white" /> : <Bot size={14} className="text-purple-300" />}
</div>
<div className={`
max-w-[80%] p-3 rounded-2xl text-sm leading-relaxed
${msg.role === 'user'
? 'bg-gray-800 text-white rounded-tr-none border border-gray-700'
: 'bg-purple-900/20 text-gray-200 rounded-tl-none border border-purple-500/20'}
`}>
{msg.content}
<div className="text-[10px] opacity-30 mt-1 text-right">
{msg.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</motion.div>
))}
{isLoading && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center shrink-0">
<Bot size={14} className="text-purple-300" />
</div>
<div className="bg-purple-900/20 p-3 rounded-2xl rounded-tl-none border border-purple-500/20 flex items-center gap-2">
<Loader2 size={16} className="animate-spin text-purple-400" />
<span className="text-xs text-purple-300">Consulting the stars...</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input Area */}
<div className="p-4 bg-gray-900/95 backdrop-blur border-t border-gray-800">
<form onSubmit={handleSend} className="relative">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Schedule a date on Friday at 7pm..."
className="w-full bg-gray-800 text-white pl-4 pr-12 py-3 rounded-xl border border-gray-700 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 outline-none placeholder-gray-500 transition-all"
disabled={isLoading}
/>
<button
type="submit"
disabled={!input.trim() || isLoading}
className="absolute right-2 top-2 p-1.5 bg-purple-600 hover:bg-purple-500 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send size={18} />
</button>
</form>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

52
components/Button.tsx Normal file
View File

@@ -0,0 +1,52 @@
import React from 'react';
import { motion } from 'framer-motion';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
roleTheme?: 'mom' | 'dad' | 'neutral';
isLoading?: boolean;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
roleTheme = 'neutral',
isLoading,
className = '',
...props
}) => {
let baseStyles = "px-6 py-3 rounded-2xl font-bold transition-all duration-200 flex items-center justify-center gap-2 outline-none focus:ring-4 disabled:opacity-50 disabled:cursor-not-allowed";
const getThemeColors = () => {
if (variant === 'ghost') return "bg-transparent hover:bg-gray-100 text-gray-600 focus:ring-gray-200";
if (variant === 'danger') return "bg-red-100 text-red-600 hover:bg-red-200 focus:ring-red-100";
if (variant === 'secondary') return "bg-white text-gray-700 border-2 border-gray-100 hover:border-gray-200 focus:ring-gray-100";
switch (roleTheme) {
case 'mom':
return "bg-mom-primary text-white hover:bg-pink-500 shadow-lg shadow-pink-200 focus:ring-pink-200";
case 'dad':
return "bg-dad-primary text-white hover:bg-blue-500 shadow-lg shadow-blue-200 focus:ring-blue-200";
default:
return "bg-gray-800 text-white hover:bg-gray-900 shadow-lg shadow-gray-300 focus:ring-gray-200";
}
};
return (
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.95 }}
className={`${baseStyles} ${getThemeColors()} ${className}`}
disabled={isLoading || props.disabled}
{...props}
>
{isLoading ? (
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
) : children}
</motion.button>
);
};

458
components/CalendarView.tsx Normal file
View File

@@ -0,0 +1,458 @@
import React, { useState, useMemo, useEffect } from 'react';
import {
format, startOfMonth, endOfMonth, startOfWeek, endOfWeek,
eachDayOfInterval, isSameMonth, isSameDay, addMonths, isToday, parseISO
} from 'date-fns';
import { motion, AnimatePresence, PanInfo } from 'framer-motion';
import { ChevronLeft, ChevronRight, Plus, LogOut, Settings as SettingsIcon, Sparkles, Bot, Moon, Sun, Zap, Cloud, Wand2, Trash2, Calendar as CalIcon, Activity, X, ChevronDown } from 'lucide-react';
import { CalendarEvent, UserRole, UserStatus, AppSettings } from '../types';
import { Button } from './Button';
import { analyzeScheduleAndSuggest } from '../services/aiService';
import { Candle } from './Candle';
import { CosmicPulseModal } from './CosmicPulseModal';
interface CalendarViewProps {
events: CalendarEvent[];
currentUser: UserRole;
currentStatus: UserStatus;
onStatusChange: (status: UserStatus) => void;
onAddEvent: (date?: Date) => void;
onEditEvent: (event: CalendarEvent) => void;
onMoveEvent: (eventId: string, newDate: Date) => void;
onLogout: () => void;
onDeleteEvent: (id: string) => void;
onOpenSettings: () => void;
onOpenAssistant: () => void;
settings: AppSettings;
}
export const CalendarView: React.FC<CalendarViewProps> = ({
events, currentUser, currentStatus, onStatusChange, onAddEvent, onEditEvent,
onLogout, onDeleteEvent, onMoveEvent, onOpenSettings, onOpenAssistant, settings
}) => {
const [currentDate, setCurrentDate] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [direction, setDirection] = useState(0);
const [showOracle, setShowOracle] = useState(false);
const [oracleWisdom, setOracleWisdom] = useState<string[]>([]);
const [isConsulting, setIsConsulting] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [dragOverDate, setDragOverDate] = useState<string | null>(null);
const [isPulseOpen, setIsPulseOpen] = useState(false);
const [isAgendaOpen, setIsAgendaOpen] = useState(false); // New state to control sheet visibility
// Mock partner
const partnerRole: UserRole = currentUser === 'mom' ? 'dad' : 'mom';
const [partnerStatus, setPartnerStatus] = useState<UserStatus>('offline');
useEffect(() => {
const partnerTimer = setInterval(() => {
const statuses: UserStatus[] = ['online', 'busy', 'casting_spells', 'thinking'];
setPartnerStatus(statuses[Math.floor(Math.random() * statuses.length)]);
}, 10000);
return () => clearInterval(partnerTimer);
}, []);
const days = useMemo(() => {
const monthStart = startOfMonth(currentDate);
const startDate = startOfWeek(monthStart);
const endDate = endOfWeek(endOfMonth(monthStart));
return eachDayOfInterval({ start: startDate, end: endDate });
}, [currentDate]);
const changeMonth = (val: number) => {
setDirection(val);
setCurrentDate(prev => addMonths(prev, val));
};
const getDayEvents = (day: Date) => {
const dayEvents = events.filter(event => isSameDay(parseISO(event.startTime), day));
return dayEvents.sort((a, b) => {
if (a.category === 'magic' && b.category !== 'magic') return -1;
if (a.category !== 'magic' && b.category === 'magic') return 1;
return new Date(a.startTime).getTime() - new Date(b.startTime).getTime();
});
};
const handleConsultOracle = async () => {
if (showOracle) { setShowOracle(false); return; }
setIsConsulting(true);
setShowOracle(true);
const wisdom = await analyzeScheduleAndSuggest(events, settings);
setOracleWisdom(wisdom);
setIsConsulting(false);
};
const getStatusIcon = (status: UserStatus) => {
switch (status) {
case 'online': return <Sun size={14} className="text-yellow-400" />;
case 'busy': return <Zap size={14} className="text-red-400" />;
case 'sleeping': return <Moon size={14} className="text-blue-300" />;
case 'casting_spells': return <Sparkles size={14} className="text-purple-400" />;
case 'thinking': return <Cloud size={14} className="text-gray-400" />;
default: return <div className="w-2 h-2 rounded-full bg-gray-500" />;
}
};
const statusOptions: {id: UserStatus, label: string, icon: any}[] = [
{ id: 'online', label: 'Present', icon: Sun },
{ id: 'busy', label: 'Busy', icon: Zap },
{ id: 'casting_spells', label: 'Magic', icon: Sparkles },
{ id: 'thinking', label: 'Thinking', icon: Cloud },
{ id: 'sleeping', label: 'Resting', icon: Moon },
];
const handleDragStart = (e: React.DragEvent, eventId: string) => {
if (!isEditMode) return;
e.dataTransfer.setData('eventId', eventId);
};
const handleDragOver = (e: React.DragEvent, dateStr: string) => {
if (!isEditMode) return;
e.preventDefault();
setDragOverDate(dateStr);
};
const handleDrop = (e: React.DragEvent, date: Date) => {
if (!isEditMode) return;
e.preventDefault();
const eventId = e.dataTransfer.getData('eventId');
if (eventId) onMoveEvent(eventId, date);
setDragOverDate(null);
};
const handleTrashDrop = (e: React.DragEvent) => {
if (!isEditMode) return;
e.preventDefault();
const eventId = e.dataTransfer.getData('eventId');
if (eventId) onDeleteEvent(eventId);
};
const handleDayClick = (day: Date) => {
setSelectedDate(day);
setIsAgendaOpen(true); // Open sheet on tap
if (isSameDay(day, selectedDate) && !isEditMode) {
// Optional double tap behavior logic
}
};
const handleSheetDragEnd = (event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
if (info.offset.y > 100 || info.velocity.y > 500) {
setIsAgendaOpen(false);
}
};
const selectedDayEvents = getDayEvents(selectedDate);
return (
<div className="max-w-7xl mx-auto p-4 md:p-8 flex flex-col h-screen max-h-screen relative z-10">
{/* Top Bar - Stacked on Mobile */}
<div className="flex flex-col md:flex-row justify-between items-center mb-4 gap-4">
{/* Status Bar */}
<div className="glass-panel rounded-full px-4 py-2 flex items-center justify-between w-full md:w-auto gap-4">
<div className="flex items-center gap-2 relative group cursor-pointer">
<div className={`w-8 h-8 rounded-full border-2 border-white/20 flex items-center justify-center ${currentUser === 'mom' ? 'bg-mom-primary' : 'bg-dad-primary'}`}>
{getStatusIcon(currentStatus)}
</div>
<span className="text-xs font-bold text-gray-300">{currentUser.toUpperCase()}</span>
<div className="absolute top-10 left-0 glass-panel rounded-xl p-2 hidden group-hover:block min-w-[150px] z-50 animate-fadeIn">
{statusOptions.map(opt => (
<button key={opt.id} onClick={() => onStatusChange(opt.id)} className="flex items-center gap-2 w-full p-2 hover:bg-white/10 rounded-lg text-left text-xs text-gray-300">
<opt.icon size={12} /> {opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2 opacity-80">
<span className="text-xs font-bold text-gray-500">{partnerRole.toUpperCase()}</span>
<div className={`w-8 h-8 rounded-full border-2 border-white/10 flex items-center justify-center grayscale opacity-70`}>
{getStatusIcon(partnerStatus)}
</div>
</div>
</div>
{/* Title */}
<div className="flex items-end justify-center">
<Candle />
<div className="text-center">
<h1 className="font-magical text-3xl md:text-5xl text-transparent bg-clip-text bg-gradient-to-r from-purple-300 via-pink-300 to-blue-300 text-glow">
Celestial
</h1>
</div>
<Candle />
</div>
{/* Tools */}
<div className="flex gap-2">
<Button variant="ghost" onClick={() => setIsPulseOpen(true)} className={`!p-2 rounded-full text-pink-400 hover:text-white hover:bg-pink-900/30`}>
<Activity size={20} className="animate-pulse" />
</Button>
<Button variant="ghost" onClick={onOpenSettings} className="!p-2 rounded-full text-gray-400 hover:text-white"><SettingsIcon size={20} /></Button>
<Button variant="ghost" onClick={onOpenAssistant} className="!p-2 rounded-full text-blue-300 hover:text-white"><Bot size={20} /></Button>
<Button variant="ghost" onClick={onLogout} className="!p-2 rounded-full text-red-400 hover:text-red-200"><LogOut size={20} /></Button>
</div>
</div>
{/* Main Glass Panel */}
<div className={`flex-1 flex flex-col glass-panel rounded-[2rem] p-4 md:p-6 shadow-2xl relative overflow-hidden border transition-colors duration-500 ${isEditMode ? 'border-pink-500/40 bg-pink-900/10' : 'border-purple-500/10'}`}>
{/* Calendar Navigation */}
<div className="flex justify-between items-center mb-4">
<button onClick={() => changeMonth(-1)} className="p-2 hover:bg-white/10 rounded-full text-purple-200"><ChevronLeft /></button>
<h2 className="text-xl font-magical font-bold text-white cursor-pointer hover:text-purple-300 transition-colors" onClick={() => setCurrentDate(new Date())}>
{format(currentDate, 'MMMM yyyy')}
</h2>
<button onClick={() => changeMonth(1)} className="p-2 hover:bg-white/10 rounded-full text-purple-200"><ChevronRight /></button>
</div>
{/* Oracle Popup */}
<AnimatePresence>
{showOracle && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="mb-4 bg-indigo-950/50 border border-indigo-500/30 rounded-2xl overflow-hidden"
>
<div className="p-4">
<h3 className="text-purple-300 font-magical text-sm mb-2 flex items-center gap-2">
<Sparkles size={14} /> The Stars Suggest...
</h3>
{isConsulting ? (
<p className="text-xs text-gray-400 animate-pulse">Gazing into the void...</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{oracleWisdom.map((wis, i) => (
<div key={i} className="bg-white/5 p-3 rounded-xl text-xs text-indigo-200 border border-white/5 hover:bg-white/10 transition-colors cursor-pointer" onClick={() => onAddEvent()}>
{wis}
</div>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Days Header */}
<div className="grid grid-cols-7 mb-2 text-center">
{['S','M','T','W','T','F','S'].map(d => (
<div key={d} className="font-magical text-purple-400/60 text-xs py-1">{d}</div>
))}
</div>
{/* Calendar Grid */}
<div className="flex-1 relative">
<AnimatePresence initial={false} custom={direction} mode="wait">
<motion.div
key={currentDate.toISOString()}
custom={direction}
variants={{
enter: (d) => ({ x: d > 0 ? 50 : -50, opacity: 0 }),
center: { x: 0, opacity: 1 },
exit: (d) => ({ x: d > 0 ? -50 : 50, opacity: 0 })
}}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.3 }}
className="grid grid-cols-7 grid-rows-5 h-full gap-1 md:gap-2 pb-20 md:pb-0"
>
{days.map((day) => {
const dayEvents = getDayEvents(day);
const isCurrentMonth = isSameMonth(day, currentDate);
const isSelected = isSameDay(day, selectedDate);
const isTodayDate = isToday(day);
const dayKey = day.toISOString();
const isDragOver = dragOverDate === dayKey;
return (
<div
key={dayKey}
onClick={() => handleDayClick(day)}
onDragOver={(e) => handleDragOver(e, dayKey)}
onDrop={(e) => handleDrop(e, day)}
className={`
relative p-1 md:p-2 rounded-xl border transition-all duration-200 flex flex-col items-center justify-start
${isSelected ? 'bg-white/10 border-purple-500/50' : 'border-transparent'}
${isDragOver ? 'bg-purple-500/30 border-purple-400 scale-105 z-10' : ''}
${isCurrentMonth ? 'opacity-100' : 'opacity-30'}
${isTodayDate ? 'shadow-[0_0_10px_rgba(168,85,247,0.3)] border-purple-500/30' : ''}
`}
>
<span className={`text-[10px] md:text-xs font-bold ${isTodayDate ? 'text-purple-300' : 'text-gray-400'}`}>
{format(day, 'd')}
</span>
{/* Desktop View: Text List */}
<div className="hidden md:flex flex-col w-full gap-1 mt-1 overflow-y-auto max-h-[80px] custom-scrollbar">
{dayEvents.map(ev => (
<div
key={ev.id}
draggable={isEditMode}
onDragStart={(e) => handleDragStart(e, ev.id)}
onClick={(e) => { e.stopPropagation(); onEditEvent(ev); }}
className={`
text-[10px] px-1 rounded truncate cursor-pointer hover:brightness-110
${ev.category === 'date' ? 'bg-red-900/60 text-red-200' :
ev.category === 'magic' ? 'bg-gradient-to-r from-purple-600 to-blue-600 text-white' :
'bg-gray-800/80 text-gray-300'}
`}
>
{ev.title}
</div>
))}
</div>
{/* Mobile View: Dots */}
<div className="flex md:hidden flex-wrap justify-center gap-1 mt-1">
{dayEvents.slice(0, 4).map(ev => (
<div
key={ev.id}
className={`w-1.5 h-1.5 rounded-full ${
ev.category === 'date' ? 'bg-red-500' :
ev.category === 'magic' ? 'bg-purple-400 shadow-[0_0_4px_#a855f7]' :
'bg-gray-500'
}`}
/>
))}
{dayEvents.length > 4 && <div className="w-1.5 h-1.5 rounded-full bg-white/50" />}
</div>
</div>
)
})}
</motion.div>
</AnimatePresence>
</div>
{/* Mobile Agenda Sheet (Replaces inline list) */}
<AnimatePresence>
{selectedDate && isAgendaOpen && (
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
drag="y"
dragConstraints={{ top: 0 }}
dragElastic={0.05}
onDragEnd={handleSheetDragEnd}
className="fixed bottom-0 left-0 right-0 z-40 bg-[#0B0B15]/95 backdrop-blur-2xl rounded-t-[2rem] border-t border-white/10 shadow-[0_-10px_50px_rgba(0,0,0,0.8)] md:hidden flex flex-col h-[50vh]"
>
{/* Drag Handle */}
<div
className="w-full flex flex-col items-center justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing touch-none"
onClick={() => setIsAgendaOpen(false)}
>
<div className="w-12 h-1.5 bg-gray-600/50 rounded-full mb-1"></div>
<ChevronDown size={14} className="text-gray-600/50" />
</div>
<div className="p-6 pt-2 flex-1 overflow-hidden flex flex-col">
{/* Sheet Header */}
<div className="flex justify-between items-center mb-4 shrink-0">
<div>
<h3 className="text-2xl font-magical text-white">{format(selectedDate, 'EEEE')}</h3>
<p className="text-sm text-purple-300 font-medium">{format(selectedDate, 'MMMM do')}</p>
</div>
<Button onClick={() => onAddEvent(selectedDate)} className="!p-0 w-10 h-10 rounded-full bg-gradient-to-tr from-purple-600 to-pink-600 shadow-lg shadow-purple-900/40">
<Plus size={20} className="text-white" />
</Button>
</div>
{/* Events List */}
<div className="space-y-3 overflow-y-auto pb-20 custom-scrollbar">
{selectedDayEvents.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-gray-500">
<Moon className="w-8 h-8 mb-2 opacity-20" />
<p className="text-sm italic">No cosmic events planned.</p>
</div>
) : (
selectedDayEvents.map(ev => (
<motion.div
key={ev.id}
layoutId={ev.id}
onClick={() => onEditEvent(ev)}
className="bg-white/5 border border-white/5 p-4 rounded-2xl flex items-center gap-4 active:scale-95 transition-transform shadow-sm"
>
<div className={`w-2 h-10 rounded-full ${
ev.category === 'date' ? 'bg-red-500 shadow-[0_0_10px_#ef4444]' :
ev.category === 'magic' ? 'bg-purple-500 shadow-[0_0_10px_#a855f7]' :
'bg-blue-500'
}`} />
<div className="flex-1 min-w-0">
<h4 className="font-bold text-lg text-white truncate">{ev.title}</h4>
<p className="text-xs text-gray-400 flex items-center gap-1">
{format(parseISO(ev.startTime), 'h:mm a')} <span className="w-1 h-1 bg-gray-600 rounded-full"/> {ev.category}
</p>
</div>
<div className="p-2 bg-white/5 rounded-full">
<ChevronRight size={16} className="text-gray-500" />
</div>
</motion.div>
))
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Edit/Trash Actions */}
<div className="absolute bottom-6 right-6 md:right-6 flex flex-col gap-3 items-center z-50 pointer-events-none">
<div className="pointer-events-auto flex flex-col gap-3">
<Button
onClick={() => setIsEditMode(!isEditMode)}
className={`!rounded-full w-12 h-12 !p-0 border shadow-lg transition-all ${isEditMode ? 'bg-pink-600 border-pink-400 animate-pulse' : 'bg-black/60 border-white/10 text-gray-400'}`}
>
<Wand2 size={20} className={isEditMode ? "text-white" : ""} />
</Button>
{/* FAB (Visible on Desktop OR when Mobile Agenda is closed) */}
<AnimatePresence>
{!isEditMode && (!isAgendaOpen || window.innerWidth >= 768) && (
<motion.div initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }}>
<Button onClick={() => onAddEvent(selectedDate)} className="!rounded-full w-14 h-14 !p-0 bg-gradient-to-tr from-purple-600 to-blue-600 shadow-[0_0_20px_rgba(124,58,237,0.4)] border border-white/20">
<Plus className="text-white" size={28} />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* The Void (Trash) */}
<AnimatePresence>
{isEditMode && (
<motion.div
initial={{ y: 100 }} animate={{ y: 0 }} exit={{ y: 100 }}
className="absolute bottom-8 left-1/2 -translate-x-1/2 z-50"
>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleTrashDrop}
className="w-20 h-20 md:w-24 md:h-24 rounded-full bg-black border-2 border-red-900 shadow-[0_0_40px_rgba(255,0,0,0.3)] flex items-center justify-center relative overflow-hidden group backdrop-blur-md"
>
<div className="absolute inset-0 bg-[conic-gradient(from_0deg,transparent_0deg,rgba(255,0,0,0.5)_360deg)] animate-spin-slow opacity-50"></div>
<Trash2 size={28} className="text-red-500/70 z-10 group-hover:text-red-200 md:w-8 md:h-8" />
<span className="absolute bottom-3 text-[8px] text-red-500 uppercase font-bold tracking-widest">Void</span>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Pulse Modal */}
<CosmicPulseModal
isOpen={isPulseOpen}
onClose={() => setIsPulseOpen(false)}
events={events}
settings={settings}
/>
</div>
);
};

23
components/Candle.tsx Normal file
View File

@@ -0,0 +1,23 @@
import React from 'react';
export const Candle: React.FC = () => {
return (
<div className="relative w-8 h-24 mx-2">
{/* Flame */}
<div className="absolute -top-6 left-1/2 -translate-x-1/2 w-4 h-8 candle-flame animate-flicker z-20 shadow-[0_0_20px_rgba(255,165,0,0.8)]"></div>
{/* Wick */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-0.5 h-2 bg-black z-10 opacity-50"></div>
{/* Candle Body */}
<div className="absolute top-2 w-full h-full bg-gradient-to-b from-purple-100 to-purple-300 rounded-t-lg shadow-inner">
{/* Drip */}
<div className="absolute top-0 left-0 w-full h-4 bg-purple-100 rounded-t-lg blur-[1px]"></div>
<div className="absolute top-2 left-1 w-2 h-6 bg-purple-100 rounded-b-full opacity-80 shadow-sm"></div>
</div>
{/* Glow Base */}
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-16 h-4 bg-purple-500/20 blur-xl rounded-full"></div>
</div>
);
};

View File

@@ -0,0 +1,173 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Activity, Heart, AlertTriangle, Zap, MessageCircle, Moon, Cloud } from 'lucide-react';
import { AppSettings, CalendarEvent, PulseAnalysis } from '../types';
import { analyzeRelationshipPulse, interpretDream } from '../services/aiService';
import { Button } from './Button';
interface CosmicPulseModalProps {
isOpen: boolean;
onClose: () => void;
events: CalendarEvent[];
settings: AppSettings;
}
export const CosmicPulseModal: React.FC<CosmicPulseModalProps> = ({ isOpen, onClose, events, settings }) => {
const [analysis, setAnalysis] = useState<PulseAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [dreamText, setDreamText] = useState('');
const [dreamResult, setDreamResult] = useState('');
const [dreamLoading, setDreamLoading] = useState(false);
useEffect(() => {
if (isOpen) {
setLoading(true);
analyzeRelationshipPulse(events, settings).then(res => {
setAnalysis(res);
setLoading(false);
});
} else {
// Reset state when closed
setAnalysis(null);
setDreamResult('');
setDreamText('');
}
}, [isOpen, events, settings]);
const handleInterpretDream = async () => {
if (!dreamText) return;
setDreamLoading(true);
const res = await interpretDream(dreamText, settings);
setDreamResult(res);
setDreamLoading(false);
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-md z-50"
onClick={onClose}
/>
<motion.div
initial={{ scale: 0.8, opacity: 0, y: 50 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.8, opacity: 0, y: 50 }}
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none p-4"
>
<div className="glass-panel w-full max-w-lg rounded-3xl overflow-hidden pointer-events-auto flex flex-col max-h-[90vh] border border-pink-500/30 shadow-[0_0_60px_rgba(236,72,153,0.3)]">
{/* Header */}
<div className="p-6 border-b border-white/10 flex justify-between items-center bg-black/40">
<h2 className="text-2xl font-magical text-transparent bg-clip-text bg-gradient-to-r from-pink-400 to-purple-400 flex items-center gap-3">
<Activity className="animate-pulse text-pink-500" /> Cosmic Pulse
</h2>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors text-gray-400">
<X size={24} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6 custom-scrollbar">
{loading ? (
<div className="flex flex-col items-center justify-center py-12 gap-4">
<div className="relative">
<div className="w-16 h-16 border-4 border-pink-500/30 border-t-pink-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Activity size={24} className="text-pink-500 animate-pulse" />
</div>
</div>
<p className="text-pink-300 font-magical animate-pulse">Analyzing Relationship Rythm...</p>
<p className="text-xs text-gray-500">Using {settings.aiProvider === 'ollama' ? 'Local Spirit (Ollama)' : 'Cloud Mind (Gemini)'}</p>
</div>
) : analysis ? (
<>
{/* Harmony Score */}
<div className="bg-gradient-to-br from-purple-900/50 to-pink-900/50 p-6 rounded-3xl border border-pink-500/20 text-center relative overflow-hidden">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/stardust.png')] opacity-20"></div>
<p className="text-pink-200 text-sm font-bold uppercase tracking-widest mb-2">Harmony Score</p>
<div className="text-6xl font-black text-white drop-shadow-[0_0_15px_rgba(236,72,153,0.5)]">
{analysis.harmonyScore}
</div>
<div className="mt-2 inline-block px-4 py-1 rounded-full bg-white/10 text-sm font-bold text-pink-200 border border-white/10">
Current Vibe: {analysis.vibe}
</div>
</div>
{/* Alerts */}
{analysis.burnoutWarning && (
<motion.div initial={{ x: -20, opacity: 0 }} animate={{ x: 0, opacity: 1 }} className="bg-red-900/30 border border-red-500/30 p-4 rounded-2xl flex items-start gap-3">
<AlertTriangle className="text-red-400 shrink-0" />
<div>
<h4 className="font-bold text-red-200 text-sm">Burnout Warning</h4>
<p className="text-xs text-red-300/80 mt-1">{analysis.burnoutWarning}</p>
</div>
</motion.div>
)}
{/* Conflicts */}
{analysis.upcomingConflicts.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2">
<Zap size={14} /> Friction Points
</h3>
{analysis.upcomingConflicts.map((c, i) => (
<div key={i} className="bg-orange-900/20 border border-orange-500/20 p-3 rounded-xl text-xs text-orange-200">
{c}
</div>
))}
</div>
)}
{/* Smart Nudges */}
<div className="space-y-3">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2">
<Heart size={14} /> Smart Nudges
</h3>
{analysis.smartNudges.map((nudge, i) => (
<div key={i} className="bg-gradient-to-r from-blue-900/30 to-purple-900/30 border border-blue-500/20 p-3 rounded-xl flex items-center gap-3">
<MessageCircle size={16} className="text-blue-300" />
<p className="text-sm text-blue-100 italic">"{nudge}"</p>
</div>
))}
</div>
</>
) : null}
{/* Dream Journal Section - Always visible */}
<div className="border-t border-white/10 pt-6">
<h3 className="text-sm font-bold text-gray-400 uppercase tracking-wider flex items-center gap-2 mb-3">
<Moon size={14} /> Dream Journal
</h3>
<div className="bg-indigo-950/40 rounded-2xl p-4 border border-indigo-500/20">
<textarea
value={dreamText}
onChange={(e) => setDreamText(e.target.value)}
placeholder="I dreamt I was flying over a grocery store made of clouds..."
className="w-full bg-black/30 border-0 rounded-xl p-3 text-sm text-indigo-100 placeholder-indigo-400/50 resize-none h-20 focus:ring-1 focus:ring-indigo-500"
/>
<div className="flex justify-between items-center mt-3">
<span className="text-[10px] text-gray-500">AI Interpreter Ready</span>
<Button size="sm" onClick={handleInterpretDream} disabled={!dreamText || dreamLoading} className="text-xs py-1 px-3 bg-indigo-600 hover:bg-indigo-500">
{dreamLoading ? <Cloud className="animate-bounce" size={14}/> : 'Interpret'}
</Button>
</div>
{dreamResult && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="mt-4 p-3 bg-white/5 rounded-xl border border-white/5">
<p className="text-xs text-purple-200 italic"> {dreamResult}</p>
</motion.div>
)}
</div>
</div>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

308
components/EventModal.tsx Normal file
View File

@@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Sparkles, Calendar as CalendarIcon, Clock, Briefcase, Heart, Home, Smile, Tag, Trash2 } from 'lucide-react';
import { UserRole, CalendarEvent, EventCategory } from '../types';
import { Button } from './Button';
import { parseNaturalLanguageEvent } from '../services/aiService';
import { getSettings } from '../services/storageService';
import { v4 as uuidv4 } from 'uuid';
interface EventModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (event: CalendarEvent) => void;
onDelete?: (id: string) => void;
currentUserRole: UserRole;
selectedDate?: Date;
initialEvent?: CalendarEvent | null;
}
export const EventModal: React.FC<EventModalProps> = ({
isOpen,
onClose,
onSave,
onDelete,
currentUserRole,
selectedDate,
initialEvent
}) => {
const [activeTab, setActiveTab] = useState<'manual' | 'ai'>('manual');
const [aiPrompt, setAiPrompt] = useState('');
const [isProcessingAi, setIsProcessingAi] = useState(false);
// Form State
const [title, setTitle] = useState('');
const [startDate, setStartDate] = useState('');
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [description, setDescription] = useState('');
const [category, setCategory] = useState<EventCategory>('general');
// Effect to load initial data for editing or reset for new
useEffect(() => {
if (isOpen) {
if (initialEvent) {
setTitle(initialEvent.title);
setDescription(initialEvent.description || '');
setCategory(initialEvent.category);
const start = new Date(initialEvent.startTime);
const end = new Date(initialEvent.endTime);
setStartDate(start.toISOString().split('T')[0]);
setStartTime(start.toTimeString().slice(0, 5));
setEndTime(end.toTimeString().slice(0, 5));
setActiveTab('manual');
} else {
// Reset
setTitle('');
setDescription('');
setCategory('general');
setAiPrompt('');
if (selectedDate) {
const year = selectedDate.getFullYear();
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
const day = String(selectedDate.getDate()).padStart(2, '0');
setStartDate(`${year}-${month}-${day}`);
} else {
setStartDate(new Date().toISOString().split('T')[0]);
}
setStartTime('09:00');
setEndTime('10:00');
setActiveTab('manual');
}
}
}, [isOpen, initialEvent, selectedDate]);
const handleAiSubmit = async () => {
if (!aiPrompt.trim()) return;
setIsProcessingAi(true);
const settings = getSettings();
const result = await parseNaturalLanguageEvent(aiPrompt, settings);
setIsProcessingAi(false);
if (result) {
setTitle(result.title);
setDescription(result.description || '');
const start = new Date(result.startTime);
const end = new Date(result.endTime);
setStartDate(start.toISOString().split('T')[0]);
setStartTime(start.toTimeString().slice(0, 5));
setEndTime(end.toTimeString().slice(0, 5));
setCategory((result.category as EventCategory) || 'general');
setActiveTab('manual'); // Switch to manual to review
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const startDateTime = new Date(`${startDate}T${startTime}`);
const endDateTime = new Date(`${startDate}T${endTime}`);
const newEvent: CalendarEvent = {
id: initialEvent ? initialEvent.id : uuidv4(),
title,
description,
startTime: startDateTime.toISOString(),
endTime: endDateTime.toISOString(),
createdBy: initialEvent ? initialEvent.createdBy : currentUserRole,
category
};
onSave(newEvent);
onClose();
};
const handleDelete = () => {
if (initialEvent && onDelete) {
onDelete(initialEvent.id);
onClose();
}
};
const categories: { id: EventCategory; label: string; icon: any; color: string }[] = [
{ id: 'general', label: 'General', icon: Tag, color: 'bg-gray-800 text-gray-300 border-gray-600' },
{ id: 'work', label: 'Work', icon: Briefcase, color: 'bg-orange-900/30 text-orange-400 border-orange-800' },
{ id: 'chore', label: 'Chore', icon: Home, color: 'bg-green-900/30 text-green-400 border-green-800' },
{ id: 'date', label: 'Date', icon: Heart, color: 'bg-red-900/30 text-red-400 border-red-800' },
{ id: 'fun', label: 'Fun', icon: Smile, color: 'bg-yellow-900/30 text-yellow-400 border-yellow-800' },
{ id: 'magic', label: 'Magic', icon: Sparkles, color: 'bg-purple-900/30 text-purple-400 border-purple-800' },
];
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
onClick={onClose}
/>
<motion.div
initial={{ scale: 0.9, opacity: 0, y: 20 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0, y: 20 }}
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none"
>
<div className="glass-panel w-full max-w-md mx-4 rounded-3xl overflow-hidden pointer-events-auto flex flex-col max-h-[90vh] border border-white/10 shadow-2xl">
{/* Header */}
<div className={`p-6 ${currentUserRole === 'mom' ? 'bg-mom-soft' : 'bg-dad-soft'} flex justify-between items-center`}>
<h2 className={`text-2xl font-extrabold ${currentUserRole === 'mom' ? 'text-mom-primary' : 'text-dad-primary'}`}>
{initialEvent ? 'Edit Event' : 'New Event'}
</h2>
<button onClick={onClose} className="p-2 hover:bg-black/20 rounded-full transition-colors">
<X size={24} className={currentUserRole === 'mom' ? 'text-mom-primary' : 'text-dad-primary'} />
</button>
</div>
{/* Tabs (Only for New Events) */}
{!initialEvent && (
<div className="flex border-b border-white/5 p-2 gap-2 bg-gray-900/30">
<button
onClick={() => setActiveTab('manual')}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all ${activeTab === 'manual' ? 'bg-gray-700 text-white' : 'text-gray-500 hover:text-gray-300'}`}
>
Manual Entry
</button>
<button
onClick={() => setActiveTab('ai')}
className={`flex-1 py-2 rounded-xl text-sm font-bold transition-all flex items-center justify-center gap-2 ${activeTab === 'ai' ? 'bg-purple-900/40 text-purple-300 border border-purple-500/20' : 'text-gray-500 hover:text-gray-300'}`}
>
<Sparkles size={16} /> Magic Plan
</button>
</div>
)}
{/* Content */}
<div className="p-6 overflow-y-auto custom-scrollbar">
{activeTab === 'ai' && !initialEvent ? (
<div className="space-y-4">
<div className="bg-purple-900/20 p-4 rounded-2xl border border-purple-500/20">
<p className="text-purple-300 text-sm font-medium mb-2">Try saying...</p>
<p className="text-purple-400/70 text-xs italic">"Dinner with Mom next Friday at 7pm"</p>
<p className="text-purple-400/70 text-xs italic">"Weekend getaway to the beach July 12-14"</p>
</div>
<textarea
value={aiPrompt}
onChange={(e) => setAiPrompt(e.target.value)}
placeholder="Describe your event..."
className="w-full h-32 p-4 rounded-2xl bg-gray-800 border-2 border-transparent focus:border-purple-500 focus:ring-0 resize-none transition-colors text-white placeholder-gray-500"
/>
<Button
onClick={handleAiSubmit}
disabled={!aiPrompt.trim()}
isLoading={isProcessingAi}
className="w-full bg-purple-600 hover:bg-purple-500 text-white"
>
<Sparkles size={18} /> Generate Details
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Event Title</label>
<input
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What's happening?"
className="w-full p-4 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 focus:border-transparent transition-all font-bold text-lg text-white placeholder-gray-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Smart Category</label>
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-none">
{categories.map((cat) => {
const Icon = cat.icon;
const isSelected = category === cat.id;
return (
<button
key={cat.id}
type="button"
onClick={() => setCategory(cat.id)}
className={`
flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border-2
${isSelected ? `${cat.color} border-current` : 'bg-gray-800 text-gray-500 border-transparent hover:bg-gray-700'}
`}
>
<Icon size={14} />
{cat.label}
</button>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Date</label>
<div className="relative">
<input
type="date"
required
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full p-3 pl-10 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 text-white"
/>
<CalendarIcon className="absolute left-3 top-3.5 text-gray-500" size={18} />
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Time</label>
<div className="flex gap-2 items-center">
<input
type="time"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full p-3 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 text-sm text-white"
/>
<span className="text-gray-500">-</span>
<input
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full p-3 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 text-sm text-white"
/>
</div>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-500 uppercase tracking-wider ml-1">Notes</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Add details, location, etc."
className="w-full p-4 rounded-2xl bg-gray-800 border-transparent focus:bg-gray-700 focus:ring-2 focus:ring-gray-600 focus:border-transparent transition-all resize-none h-24 text-white placeholder-gray-500"
/>
</div>
<div className="pt-2 flex gap-3">
{initialEvent && (
<Button type="button" variant="danger" onClick={handleDelete} className="flex-1">
<Trash2 size={18} /> Delete
</Button>
)}
<Button type="submit" roleTheme={currentUserRole} className="flex-[2]">
{initialEvent ? 'Save Changes' : 'Create Event'}
</Button>
</div>
</form>
)}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

76
components/FairyDust.tsx Normal file
View File

@@ -0,0 +1,76 @@
import React, { useEffect, useState } from 'react';
interface Point {
x: number;
y: number;
id: number;
size: number;
color: string;
velocity: { x: number; y: number };
life: number;
}
export const FairyDust: React.FC = () => {
const [points, setPoints] = useState<Point[]>([]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const colors = ['#FF00CC', '#3333FF', '#7000FF', '#FFFFFF', '#00FFFF'];
const newPoint: Point = {
x: e.clientX,
y: e.clientY,
id: Date.now() + Math.random(),
size: Math.random() * 4 + 2,
color: colors[Math.floor(Math.random() * colors.length)],
velocity: {
x: (Math.random() - 0.5) * 2,
y: (Math.random() - 0.5) * 2
},
life: 1.0
};
setPoints(prev => [...prev.slice(-40), newPoint]); // Limit trail length
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
useEffect(() => {
const interval = setInterval(() => {
setPoints(prev => prev
.map(p => ({
...p,
x: p.x + p.velocity.x,
y: p.y + p.velocity.y,
life: p.life - 0.05
}))
.filter(p => p.life > 0)
);
}, 16); // 60fps
return () => clearInterval(interval);
}, []);
return (
<div className="fixed inset-0 pointer-events-none z-[9999]">
{points.map(point => (
<div
key={point.id}
style={{
position: 'absolute',
left: point.x,
top: point.y,
width: point.size,
height: point.size,
backgroundColor: point.color,
borderRadius: '50%',
opacity: point.life,
transform: `scale(${point.life})`,
boxShadow: `0 0 ${point.size * 2}px ${point.color}`,
transition: 'opacity 0.1s linear'
}}
/>
))}
</div>
);
};

106
components/LoginScreen.tsx Normal file
View File

@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { UserRole } from '../types';
import { Button } from './Button';
import { Heart } from 'lucide-react';
interface LoginScreenProps {
onLogin: (role: UserRole) => void;
}
export const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
const user = username.toLowerCase().trim();
const pass = password.trim();
if (pass !== 'password') {
setError('Wrong password! Hint: password');
return;
}
if (user === 'mom') {
onLogin('mom');
} else if (user === 'dad') {
onLogin('dad');
} else {
setError('Username must be "mom" or "dad"');
}
};
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gray-900">
{/* Background decorative blobs */}
<div className="fixed top-0 left-0 w-64 h-64 bg-purple-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
<div className="fixed top-0 right-0 w-64 h-64 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
<div className="fixed -bottom-8 left-20 w-64 h-64 bg-pink-600 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5 }}
className="glass-panel p-8 rounded-[2.5rem] shadow-2xl w-full max-w-sm border border-white/10 relative z-10"
>
<div className="text-center mb-8">
<div className="w-20 h-20 bg-gradient-to-tr from-pink-500 to-blue-500 rounded-full mx-auto mb-4 flex items-center justify-center shadow-lg shadow-purple-500/20">
<Heart className="text-white fill-white" size={40} />
</div>
<h1 className="text-3xl font-black text-white tracking-tight">Welcome Home</h1>
<p className="text-gray-400 font-medium mt-1">Shared Calendar App</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<input
type="text"
placeholder="Who are you? (mom/dad)"
value={username}
onChange={(e) => {
setUsername(e.target.value);
setError('');
}}
className="w-full px-6 py-4 rounded-2xl bg-gray-800 border-2 border-transparent focus:bg-gray-700 focus:border-blue-500 focus:ring-0 transition-all font-bold text-gray-200 placeholder-gray-500 outline-none"
/>
</div>
<div>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
className="w-full px-6 py-4 rounded-2xl bg-gray-800 border-2 border-transparent focus:bg-gray-700 focus:border-blue-500 focus:ring-0 transition-all font-bold text-gray-200 placeholder-gray-500 outline-none"
/>
</div>
{error && (
<motion.p
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
className="text-red-400 text-sm font-bold text-center bg-red-900/20 py-2 rounded-xl border border-red-900/50"
>
{error}
</motion.p>
)}
<Button
type="submit"
className="w-full bg-gradient-to-r from-mom-primary to-dad-primary hover:from-pink-400 hover:to-blue-400 shadow-lg shadow-purple-900/50 text-white border-0"
>
Enter House
</Button>
</form>
<p className="text-center text-xs text-gray-500 mt-8 font-semibold">
Secure Shared Simple
</p>
</motion.div>
</div>
);
};

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Save, Server, Palette, Cpu, BookOpen, Star, Cat, Cloud, Database } from 'lucide-react';
import { AppSettings, ZodiacSign, Familiar, AIProvider } from '../types';
import { Button } from './Button';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
settings: AppSettings;
onSave: (settings: AppSettings) => void;
}
const zodiacSigns: ZodiacSign[] = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces'];
const familiars: Familiar[] = ['Cat', 'Owl', 'Toad', 'Bat', 'Crow', 'Wolf', 'None'];
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, settings, onSave }) => {
const [localSettings, setLocalSettings] = useState<AppSettings>(settings);
const [activeTab, setActiveTab] = useState<'general' | 'identity' | 'ai'>('general');
const handleSave = () => {
onSave(localSettings);
onClose();
};
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50"
onClick={onClose}
/>
<motion.div
initial={{ scale: 0.9, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.9, opacity: 0 }}
className="fixed inset-0 flex items-center justify-center z-50 pointer-events-none"
>
<div className="glass-panel w-full max-w-lg mx-4 rounded-3xl overflow-hidden pointer-events-auto flex flex-col max-h-[85vh] border border-purple-500/20 shadow-[0_0_50px_rgba(112,0,255,0.2)]">
{/* Header */}
<div className="p-6 border-b border-white/10 flex justify-between items-center bg-black/40">
<h2 className="text-2xl font-magical text-purple-300 flex items-center gap-2">
<BookOpen size={24} /> The Grimoire
</h2>
<button onClick={onClose} className="p-2 hover:bg-white/10 rounded-full transition-colors">
<X size={24} className="text-gray-400" />
</button>
</div>
{/* Tabs */}
<div className="flex p-2 gap-2 bg-black/20 overflow-x-auto">
<button onClick={() => setActiveTab('general')} className={`flex-1 py-2 px-3 whitespace-nowrap rounded-xl text-xs font-bold transition-all ${activeTab === 'general' ? 'bg-purple-900/40 text-purple-200 border border-purple-500/30' : 'text-gray-500'}`}>
Runes & Colors
</button>
<button onClick={() => setActiveTab('identity')} className={`flex-1 py-2 px-3 whitespace-nowrap rounded-xl text-xs font-bold transition-all ${activeTab === 'identity' ? 'bg-pink-900/40 text-pink-200 border border-pink-500/30' : 'text-gray-500'}`}>
Cosmic Identity
</button>
<button onClick={() => setActiveTab('ai')} className={`flex-1 py-2 px-3 whitespace-nowrap rounded-xl text-xs font-bold transition-all ${activeTab === 'ai' ? 'bg-blue-900/40 text-blue-200 border border-blue-500/30' : 'text-gray-500'}`}>
Brain & Spirit
</button>
</div>
<div className="p-6 overflow-y-auto custom-scrollbar space-y-6">
{activeTab === 'general' ? (
<>
<div>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<Palette size={14} /> Aura Colors
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<label className="text-xs font-semibold text-gray-400 block mb-2">Mom's Aura</label>
<input type="color" value={localSettings.momColor} onChange={(e) => setLocalSettings({...localSettings, momColor: e.target.value})} className="w-full h-10 rounded bg-transparent cursor-pointer" />
</div>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<label className="text-xs font-semibold text-gray-400 block mb-2">Dad's Aura</label>
<input type="color" value={localSettings.dadColor} onChange={(e) => setLocalSettings({...localSettings, dadColor: e.target.value})} className="w-full h-10 rounded bg-transparent cursor-pointer" />
</div>
</div>
</div>
<div>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<Server size={14} /> Telepathy Link (SSE)
</h3>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5 space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input type="checkbox" checked={localSettings.enableMcp} onChange={(e) => setLocalSettings({...localSettings, enableMcp: e.target.checked})} className="w-4 h-4 rounded bg-gray-800 border-gray-600 text-purple-500" />
<span className="text-sm font-medium text-gray-300">Enable Neural Sync</span>
</label>
{localSettings.enableMcp && (
<input type="text" value={localSettings.mcpServerUrl} onChange={(e) => setLocalSettings({...localSettings, mcpServerUrl: e.target.value})} className="w-full p-3 rounded-xl bg-black/50 border border-white/10 text-gray-300 text-sm font-mono focus:border-purple-500 outline-none" placeholder="http://server..." />
)}
</div>
</div>
</>
) : activeTab === 'identity' ? (
<>
<div>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<Star size={14} /> Star Signs
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<label className="text-xs font-semibold text-gray-400 block mb-2">Mom</label>
<select value={localSettings.momZodiac} onChange={(e) => setLocalSettings({...localSettings, momZodiac: e.target.value as ZodiacSign})} className="w-full bg-black/50 text-gray-200 text-sm rounded-lg p-2 border border-white/10 outline-none">
{zodiacSigns.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<label className="text-xs font-semibold text-gray-400 block mb-2">Dad</label>
<select value={localSettings.dadZodiac} onChange={(e) => setLocalSettings({...localSettings, dadZodiac: e.target.value as ZodiacSign})} className="w-full bg-black/50 text-gray-200 text-sm rounded-lg p-2 border border-white/10 outline-none">
{zodiacSigns.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
</div>
<div>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<Cat size={14} /> House Familiar
</h3>
<div className="bg-white/5 p-4 rounded-2xl border border-white/5">
<select value={localSettings.familiar} onChange={(e) => setLocalSettings({...localSettings, familiar: e.target.value as Familiar})} className="w-full bg-black/50 text-gray-200 text-sm rounded-lg p-2 border border-white/10 outline-none">
{familiars.map(f => <option key={f} value={f}>{f}</option>)}
</select>
<p className="text-[10px] text-gray-500 mt-2">Your spiritual guardian.</p>
</div>
</div>
</>
) : (
<>
<div>
<h3 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-3 flex items-center gap-2">
<Cpu size={14} /> Intelligence Source
</h3>
<div className="flex bg-black/50 p-1 rounded-xl border border-white/10 mb-4">
<button
onClick={() => setLocalSettings({...localSettings, aiProvider: 'gemini'})}
className={`flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all ${localSettings.aiProvider === 'gemini' ? 'bg-purple-600 text-white shadow-lg' : 'text-gray-400 hover:text-white'}`}
>
<Cloud size={14} /> Gemini
</button>
<button
onClick={() => setLocalSettings({...localSettings, aiProvider: 'ollama'})}
className={`flex-1 py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all ${localSettings.aiProvider === 'ollama' ? 'bg-blue-600 text-white shadow-lg' : 'text-gray-400 hover:text-white'}`}
>
<Database size={14} /> Ollama
</button>
</div>
</div>
{localSettings.aiProvider === 'ollama' && (
<div className="space-y-4 animate-fadeIn">
<div className="bg-blue-900/10 p-4 rounded-2xl border border-blue-500/20">
<h4 className="text-blue-300 font-bold text-sm flex items-center gap-2"><Cpu size={16} /> Local Spirit</h4>
<p className="text-blue-400/60 text-xs mt-1">Ensure Ollama is running with CORS enabled.</p>
</div>
<div>
<label className="text-xs font-semibold text-gray-400 block mb-1">URL</label>
<input type="text" value={localSettings.ollamaUrl} onChange={(e) => setLocalSettings({...localSettings, ollamaUrl: e.target.value})} className="w-full p-3 rounded-xl bg-black/50 border border-white/10 text-gray-300 text-sm font-mono focus:border-blue-500 outline-none" placeholder="http://localhost:11434" />
</div>
<div>
<label className="text-xs font-semibold text-gray-400 block mb-1">Model</label>
<input type="text" value={localSettings.ollamaModel} onChange={(e) => setLocalSettings({...localSettings, ollamaModel: e.target.value})} className="w-full p-3 rounded-xl bg-black/50 border border-white/10 text-gray-300 text-sm font-mono focus:border-blue-500 outline-none" placeholder="llama3" />
</div>
</div>
)}
</>
)}
</div>
<div className="p-6 border-t border-white/10 bg-black/40">
<Button onClick={handleSave} className="w-full bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-500 hover:to-blue-500 text-white shadow-lg shadow-purple-900/50">
<Save size={18} /> Inscribe Changes
</Button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,54 @@
import React from 'react';
export const SpaceBackground: React.FC = () => {
return (
<div className="fixed inset-0 z-[-1] overflow-hidden bg-[#050510]">
{/* Deep Space Gradients */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(17,24,39,0)_0%,#050510_100%)]" />
<div className="absolute top-[-20%] left-[-20%] w-[80%] h-[80%] bg-purple-900/10 rounded-full blur-[150px] animate-pulse" />
<div className="absolute bottom-[-20%] right-[-20%] w-[80%] h-[80%] bg-blue-900/10 rounded-full blur-[150px] animate-pulse" style={{ animationDelay: '3s' }} />
{/* Stars */}
<div className="absolute inset-0 opacity-60">
{[...Array(80)].map((_, i) => (
<div
key={i}
className="absolute rounded-full bg-white animate-twinkle"
style={{
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
width: `${Math.random() * 2 + 1}px`,
height: `${Math.random() * 2 + 1}px`,
animationDelay: `${Math.random() * 5}s`,
boxShadow: `0 0 ${Math.random() * 4}px rgba(255, 255, 255, 0.8)`
}}
/>
))}
</div>
{/* Shooting Stars */}
<div className="absolute top-[10%] left-[80%] w-[2px] h-[2px] bg-white shadow-[0_0_20px_2px_white] animate-shoot opacity-0" />
<div className="absolute top-[30%] left-[90%] w-[2px] h-[2px] bg-white shadow-[0_0_20px_2px_white] animate-shoot opacity-0" style={{ animationDelay: '5s' }} />
<div className="absolute top-[5%] left-[60%] w-[3px] h-[3px] bg-cyan-200 shadow-[0_0_20px_2px_cyan] animate-shoot opacity-0" style={{ animationDelay: '12s', animationDuration: '6s' }} />
{/* Nebula Blobs - More intense */}
<div className="absolute top-1/4 left-1/4 w-[400px] h-[400px] bg-pink-900/10 rounded-full blur-[100px] animate-float" style={{ animationDuration: '20s' }} />
<div className="absolute bottom-1/4 right-1/4 w-[500px] h-[500px] bg-indigo-900/10 rounded-full blur-[120px] animate-float" style={{ animationDelay: '2s', animationDuration: '25s' }} />
{/* Orbiting Planets - Detailed */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[900px] border border-white/5 rounded-full animate-spin-slow">
{/* Planet 1 */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-gradient-to-br from-blue-400 to-blue-900 shadow-[0_0_30px_rgba(59,130,246,0.4)]">
<div className="absolute inset-0 rounded-full bg-black/20" style={{ clipPath: 'inset(0 0 50% 0)' }}></div>
</div>
</div>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-white/5 rounded-full animate-spin-slow" style={{ animationDirection: 'reverse', animationDuration: '90s' }}>
{/* Planet 2 */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 w-8 h-8 rounded-full bg-gradient-to-br from-purple-400 to-purple-900 shadow-[0_0_20px_rgba(168,85,247,0.4)]"></div>
</div>
</div>
);
};

114
index.html Normal file
View File

@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Celestial Time - Our Grimoire</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['Nunito', 'sans-serif'],
magical: ['Cinzel', 'serif'],
},
colors: {
'cosmic-dark': '#0B0B15',
'cosmic-card': 'rgba(20, 20, 40, 0.6)',
'neon-pink': '#FF00CC',
'neon-blue': '#3333FF',
'witch-purple': '#7000FF',
},
animation: {
'spin-slow': 'spin 120s linear infinite',
'float': 'float 6s ease-in-out infinite',
'twinkle': 'twinkle 4s ease-in-out infinite',
'flicker': 'flicker 0.1s infinite alternate',
'shoot': 'shoot 4s ease-in-out infinite',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-20px)' },
},
twinkle: {
'0%, 100%': { opacity: '0.2' },
'50%': { opacity: '1' },
},
flicker: {
'0%': { transform: 'scale(1)', opacity: '1' },
'50%': { transform: 'scale(0.98) skewX(1deg)', opacity: '0.9' },
'100%': { transform: 'scale(1.02) skewX(-1deg)', opacity: '0.8' },
},
shoot: {
'0%': { transform: 'translateX(0) translateY(0) rotate(45deg) scale(0)', opacity: '0' },
'10%': { opacity: '1', transform: 'translateX(-50px) translateY(50px) rotate(45deg) scale(1)' },
'20%': { transform: 'translateX(-300px) translateY(300px) rotate(45deg) scale(0.5)', opacity: '0' },
'100%': { transform: 'translateX(-300px) translateY(300px) rotate(45deg) scale(0)', opacity: '0' }
}
}
}
}
}
</script>
<style>
body {
background-color: #050510;
color: #E2E8F0;
overflow-x: hidden;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: rgba(0,0,0,0.3);
}
::-webkit-scrollbar-thumb {
background: rgba(112, 0, 255, 0.3);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(112, 0, 255, 0.6);
}
.glass-panel {
background: rgba(15, 15, 30, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
.text-glow {
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5), 0 0 20px rgba(112, 0, 255, 0.3);
}
.candle-flame {
background: radial-gradient(ellipse at bottom, #ffee00 0%, #ff6600 50%, transparent 80%);
border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%;
filter: blur(1px);
}
</style>
<script type="importmap">
{
"imports": {
"uuid": "https://esm.sh/uuid@^13.0.0",
"react": "https://esm.sh/react@^19.2.4",
"react/": "https://esm.sh/react@^19.2.4/",
"framer-motion": "https://esm.sh/framer-motion@^12.34.0",
"lucide-react": "https://esm.sh/lucide-react@^0.564.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
"@google/genai": "https://esm.sh/@google/genai@^1.41.0",
"date-fns": "https://esm.sh/date-fns@^4.1.0"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "OurTime",
"description": "A bubbly, cute, and modern shared calendar for couples. Features easy event planning, shared views, and AI-powered smart scheduling.",
"requestFramePermissions": []
}

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "ourtime",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"uuid": "^13.0.0",
"react": "^19.2.4",
"framer-motion": "^12.34.0",
"lucide-react": "^0.564.0",
"react-dom": "^19.2.4",
"@google/genai": "^1.41.0",
"date-fns": "^4.1.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

284
services/aiService.ts Normal file
View File

@@ -0,0 +1,284 @@
import { GoogleGenAI, Type } from "@google/genai";
import { EventCategory, CalendarEvent, AppSettings, PulseAnalysis } from "../types";
import { v4 as uuidv4 } from 'uuid';
const apiKey = process.env.API_KEY || '';
const geminiAi = new GoogleGenAI({ apiKey });
// --- Helper for Ollama ---
const callOllama = async (settings: AppSettings, prompt: string, jsonMode: boolean = true) => {
try {
const response = await fetch(`${settings.ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollamaModel,
prompt: prompt,
stream: false,
format: jsonMode ? "json" : undefined
})
});
if (!response.ok) throw new Error("Ollama connection failed. Check your IP/Port and CORS settings.");
const data = await response.json();
return jsonMode ? JSON.parse(data.response) : data.response;
} catch (error) {
console.error("Ollama Error:", error);
throw error;
}
};
// --- Helper for Gemini ---
const callGemini = async (prompt: string, schema?: any) => {
try {
const model = 'gemini-3-flash-preview';
const config: any = {};
if (schema) {
config.responseMimeType = "application/json";
config.responseSchema = schema;
}
const response = await geminiAi.models.generateContent({
model,
contents: prompt,
config
});
return schema ? JSON.parse(response.text || "{}") : response.text;
} catch (error) {
console.error("Gemini Error:", error);
throw error;
}
};
// --- Features ---
export const parseNaturalLanguageEvent = async (
input: string,
settings: AppSettings
): Promise<{ title: string; startTime: string; endTime: string; description?: string; category: EventCategory } | null> => {
const referenceDate = new Date();
const prompt = `
Current Date context: ${referenceDate.toISOString()}
User Input: "${input}"
Extract event details. Return JSON.
Categorize into one of: 'general', 'chore', 'date', 'work', 'fun'.
Required fields: title, startTime (ISO), endTime (ISO), category.
If exact time not given, guess reasonable time.
`;
try {
if (settings.aiProvider === 'ollama') {
return await callOllama(settings, prompt, true);
} else {
return await callGemini(prompt, {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
startTime: { type: Type.STRING },
endTime: { type: Type.STRING },
description: { type: Type.STRING },
category: { type: Type.STRING, enum: ['general', 'chore', 'date', 'work', 'fun'] }
},
required: ["title", "startTime", "endTime", "category"]
});
}
} catch (e) {
console.warn("AI Event Parse Failed");
return null;
}
};
export const analyzeRelationshipPulse = async (
events: CalendarEvent[],
settings: AppSettings
): Promise<PulseAnalysis> => {
const now = new Date();
const futureEvents = events.filter(e => new Date(e.startTime) > now).slice(0, 30);
const summary = futureEvents.map(e =>
`[${e.createdBy.toUpperCase()}] ${e.title} (${e.category}) on ${e.startTime}`
).join('\n');
const prompt = `
Analyze this couple's schedule for the "Cosmic Pulse" dashboard.
Events:
${summary}
Tasks:
1. Calculate "harmonyScore" (0-100). Higher if balanced mix of work/chores vs dates/fun. Lower if too much work or undefined.
2. "vibe": One word (e.g., Chaotic, Romantic, Busy).
3. "burnoutWarning": If one person has too many 'work'/'chore' items, warn them. Null if fine.
4. "upcomingConflicts": Identify any potential timing clashes.
5. "smartNudges": Suggest 2 nice things they can say/do based on schedule (e.g. "Wish Mom luck on presentation").
Return JSON matching PulseAnalysis interface.
`;
const fallback: PulseAnalysis = {
harmonyScore: 85,
vibe: "Stable",
burnoutWarning: null,
upcomingConflicts: [],
smartNudges: ["Hug each other", "Plan a date"]
};
try {
let result;
if (settings.aiProvider === 'ollama') {
result = await callOllama(settings, prompt, true);
} else {
result = await callGemini(prompt, {
type: Type.OBJECT,
properties: {
harmonyScore: { type: Type.NUMBER },
vibe: { type: Type.STRING },
burnoutWarning: { type: Type.STRING, nullable: true },
upcomingConflicts: { type: Type.ARRAY, items: { type: Type.STRING } },
smartNudges: { type: Type.ARRAY, items: { type: Type.STRING } }
},
required: ["harmonyScore", "vibe", "smartNudges"]
});
}
return { ...fallback, ...result };
} catch (e) {
console.error("Pulse check failed", e);
return fallback;
}
};
export const interpretDream = async (
dreamText: string,
settings: AppSettings
): Promise<string> => {
const prompt = `
Interpret this dream in a mystical, positive, and bubbly way.
Keep it short (max 2 sentences).
Dream: "${dreamText}"
Return JSON: { "interpretation": "string" }
`;
try {
let res;
if (settings.aiProvider === 'ollama') {
res = await callOllama(settings, prompt, true);
} else {
res = await callGemini(prompt, {
type: Type.OBJECT,
properties: { interpretation: { type: Type.STRING } }
});
}
return res.interpretation || "A mysterious vision of the future.";
} catch (e) {
return "The mists of time obscure this meaning.";
}
};
export const generateChatResponse = async (
input: string,
settings: AppSettings,
userRole: string
): Promise<{ text: string; event?: CalendarEvent }> => {
const prompt = `
You are a mystical calendar assistant.
Current Date: ${new Date().toISOString()}
User Role: ${userRole}
If request is to create/add/schedule event:
Output JSON with "isEvent": true and all event fields.
Else:
Output JSON with "isEvent": false and "message".
User: ${input}
`;
try {
let parsed;
if (settings.aiProvider === 'ollama') {
parsed = await callOllama(settings, prompt, true);
} else {
parsed = await callGemini(prompt, {
type: Type.OBJECT,
properties: {
isEvent: { type: Type.BOOLEAN },
message: { type: Type.STRING },
title: { type: Type.STRING },
description: { type: Type.STRING },
startTime: { type: Type.STRING },
endTime: { type: Type.STRING },
category: { type: Type.STRING, enum: ['general', 'chore', 'date', 'work', 'fun', 'magic'] }
},
required: ["isEvent"]
});
}
if (parsed.isEvent) {
const newEvent: CalendarEvent = {
id: uuidv4(),
title: parsed.title || "New Event",
description: parsed.description || "",
startTime: parsed.startTime || new Date().toISOString(),
endTime: parsed.endTime || new Date().toISOString(),
category: (parsed.category as EventCategory) || 'general',
createdBy: userRole as any
};
return { text: parsed.message || `Scheduled "${parsed.title}"!`, event: newEvent };
} else {
return { text: parsed.message || "I heard the stars whispering." };
}
} catch (e) {
return { text: "The connection to the oracle is hazy." };
}
};
export const generateDailyHoroscope = async (
momSign: string,
dadSign: string,
settings: AppSettings
): Promise<string> => {
const prompt = `
Generate a mystical, short love horoscope (max 10 words) for signs ${momSign} and ${dadSign}.
Return JSON: { "prediction": "string" }
`;
try {
let parsed;
if (settings.aiProvider === 'ollama') {
parsed = await callOllama(settings, prompt, true);
} else {
parsed = await callGemini(prompt, {
type: Type.OBJECT,
properties: { prediction: { type: Type.STRING } }
});
}
return parsed.prediction || "Love is in the air.";
} catch (e) {
return "Stars align for you two.";
}
};
export const analyzeScheduleAndSuggest = async (
events: CalendarEvent[],
settings: AppSettings
): Promise<string[]> => {
const eventsSummary = events.slice(0, 20).map(e => `- ${e.title} (${e.category})`).join('\n');
const prompt = `
Analyze this schedule:
${eventsSummary}
Suggest 3 fun, specific events for a couple to add.
Return JSON: { "suggestions": ["string", "string", "string"] }
`;
try {
let parsed;
if (settings.aiProvider === 'ollama') {
parsed = await callOllama(settings, prompt, true);
} else {
parsed = await callGemini(prompt, {
type: Type.OBJECT,
properties: { suggestions: { type: Type.ARRAY, items: { type: Type.STRING } } }
});
}
return parsed.suggestions || ["Date Night", "Walk in Park", "Movie Marathon"];
} catch (e) {
return ["Plan a date", "Relax together", "Cook dinner"];
}
};

198
services/geminiService.ts Normal file
View File

@@ -0,0 +1,198 @@
import { GoogleGenAI, Type } from "@google/genai";
import { EventCategory, CalendarEvent, AppSettings } from "../types";
import { v4 as uuidv4 } from 'uuid';
const apiKey = process.env.API_KEY || '';
const ai = new GoogleGenAI({ apiKey });
// --- Event Parsing ---
export const parseNaturalLanguageEvent = async (
input: string,
referenceDate: Date = new Date()
): Promise<{ title: string; startTime: string; endTime: string; description?: string; category: EventCategory } | null> => {
if (!apiKey) {
console.warn("No API Key found for Gemini");
return null;
}
try {
const model = 'gemini-3-flash-preview';
const prompt = `
Current Date context: ${referenceDate.toISOString()}
User Input: "${input}"
Extract event details.
Categorize into one of: 'general', 'chore', 'date', 'work', 'fun'.
If it sounds romantic, it's a date. If it's cleaning/shopping, it's a chore.
`;
const response = await ai.models.generateContent({
model,
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
startTime: { type: Type.STRING, description: "ISO 8601 format date string" },
endTime: { type: Type.STRING, description: "ISO 8601 format date string" },
description: { type: Type.STRING },
category: { type: Type.STRING, enum: ['general', 'chore', 'date', 'work', 'fun'] }
},
required: ["title", "startTime", "endTime", "category"]
}
}
});
const text = response.text;
if (!text) return null;
return JSON.parse(text);
} catch (error) {
console.error("Error parsing event with Gemini:", error);
return null;
}
};
// --- Chat & Assistant ---
export const generateChatResponse = async (
input: string,
settings: AppSettings,
userRole: string
): Promise<{ text: string; event?: CalendarEvent }> => {
if (!apiKey) return { text: "I need a Gemini API Key to function properly." };
try {
const model = 'gemini-3-flash-preview';
const systemPrompt = `
You are a mystical calendar oracle for a couple.
Current Date: ${new Date().toISOString()}
User Role: ${userRole}
Mom's Sign: ${settings.momZodiac}
Dad's Sign: ${settings.dadZodiac}
If the user asks to create an event, output JSON with "isEvent": true.
Otherwise, output JSON with "isEvent": false and a "message".
Keep messages bubbly, mystical, and concise.
`;
const response = await ai.models.generateContent({
model,
contents: `${systemPrompt}\n\nUser: ${input}`,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
isEvent: { type: Type.BOOLEAN },
message: { type: Type.STRING },
title: { type: Type.STRING },
description: { type: Type.STRING },
startTime: { type: Type.STRING },
endTime: { type: Type.STRING },
category: { type: Type.STRING, enum: ['general', 'chore', 'date', 'work', 'fun', 'magic'] }
},
required: ["isEvent"]
}
}
});
const parsed = JSON.parse(response.text || "{}");
if (parsed.isEvent) {
const newEvent: CalendarEvent = {
id: uuidv4(),
title: parsed.title || "New Event",
description: parsed.description || "",
startTime: parsed.startTime || new Date().toISOString(),
endTime: parsed.endTime || new Date().toISOString(),
category: (parsed.category as EventCategory) || 'general',
createdBy: userRole as any
};
return { text: parsed.message || `I've scheduled "${parsed.title}" for you!`, event: newEvent };
} else {
return { text: parsed.message || "The stars are silent." };
}
} catch (error) {
console.error("Gemini Chat Error", error);
return { text: "The cosmic connection is weak right now." };
}
};
// --- Daily Horoscope ---
export const generateDailyHoroscope = async (
momSign: string,
dadSign: string,
settings: AppSettings
): Promise<string> => {
if (!apiKey) return "Magic requires an API Key.";
try {
const model = 'gemini-3-flash-preview';
const prompt = `
Generate a very short, mystical horoscope prediction (max 10 words) for a couple with signs ${momSign} and ${dadSign} for today.
Focus on their shared energy, love, or domestic life.
`;
const response = await ai.models.generateContent({
model,
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
prediction: { type: Type.STRING }
}
}
}
});
const parsed = JSON.parse(response.text || "{}");
return parsed.prediction || "Cosmic energy flows through your home.";
} catch (e) {
return "The stars are aligning.";
}
};
// --- Schedule Suggestions ---
export const analyzeScheduleAndSuggest = async (
events: CalendarEvent[],
settings: AppSettings
): Promise<string[]> => {
if (!apiKey) return ["Plan a surprise dinner!", "Go for a moonlit walk.", "Cook a meal together."];
try {
const model = 'gemini-3-flash-preview';
const eventsSummary = events.slice(0, 20).map(e => // Limit to 20 for context window efficiency
`- ${e.title} (${e.category}) on ${e.startTime}`
).join('\n');
const prompt = `
Analyze this couple's schedule:
${eventsSummary}
Based on these events, suggest 3 specific, thoughtful, or fun additional events they should add to balance their life.
Look for missing date nights, overwhelming work blocks, or chores that need doing.
Return 3 short strings.
`;
const response = await ai.models.generateContent({
model,
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.ARRAY,
items: { type: Type.STRING }
}
}
});
return JSON.parse(response.text || "[]");
} catch (e) {
return ["Plan a date night", "Take a rest day", "Organize the pantry"];
}
};

64
services/mcpService.ts Normal file
View File

@@ -0,0 +1,64 @@
import { CalendarEvent } from '../types';
type EventCallback = (event: CalendarEvent) => void;
class MCPService {
private eventSource: EventSource | null = null;
private listeners: EventCallback[] = [];
connect(url: string) {
if (this.eventSource) {
this.eventSource.close();
}
try {
console.log(`Connecting to MCP Brain at ${url}...`);
this.eventSource = new EventSource(url);
this.eventSource.onopen = () => {
console.log('MCP Brain Connected!');
};
this.eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Assuming the backend sends an event structure similar to ours
// or wraps it in { type: 'new_event', payload: ... }
if (data.id && data.title) {
this.notify(data);
}
} catch (e) {
console.error('Failed to parse MCP message', e);
}
};
this.eventSource.onerror = (err) => {
console.error('MCP Connection Error', err);
// Optional: Implement retry logic here
};
} catch (e) {
console.error('Invalid URL for MCP', e);
}
}
disconnect() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
subscribe(callback: EventCallback) {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter(l => l !== callback);
};
}
private notify(event: CalendarEvent) {
this.listeners.forEach(cb => cb(event));
}
}
export const mcpService = new MCPService();

156
services/ollamaService.ts Normal file
View File

@@ -0,0 +1,156 @@
import { CalendarEvent, EventCategory, AppSettings } from '../types';
import { v4 as uuidv4 } from 'uuid';
interface OllamaResponse {
model: string;
created_at: string;
response: string;
done: boolean;
}
export const generateOllamaResponse = async (
prompt: string,
settings: AppSettings,
userRole: string
): Promise<{ text: string; event?: CalendarEvent }> => {
const url = `${settings.ollamaUrl}/api/generate`;
const currentDate = new Date();
const systemPrompt = `
You are a mystical calendar oracle for a couple.
Current Date: ${currentDate.toISOString()}
User Role: ${userRole}
Output JSON ONLY.
If user wants an event:
{
"isEvent": true,
"title": "string",
"description": "string",
"startTime": "ISO 8601 string",
"endTime": "ISO 8601 string",
"category": "general" | "chore" | "date" | "work" | "fun" | "magic"
}
If chatting:
{
"isEvent": false,
"message": "your magical response here"
}
`;
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollamaModel,
prompt: `${systemPrompt}\n\nUser: ${prompt}`,
stream: false,
format: "json"
})
});
if (!response.ok) throw new Error(`Ollama Error`);
const data: OllamaResponse = await response.json();
const resultText = data.response;
const parsed = JSON.parse(resultText);
if (parsed.isEvent) {
const newEvent: CalendarEvent = {
id: uuidv4(),
title: parsed.title,
description: parsed.description,
startTime: parsed.startTime,
endTime: parsed.endTime,
category: parsed.category || 'general',
createdBy: userRole as any
};
return { text: `The stars have aligned for "${parsed.title}".`, event: newEvent };
} else {
return { text: parsed.message || "The spirits are quiet." };
}
} catch (error) {
console.error("Ollama Error", error);
return { text: "I cannot reach the local spirit realm (Ollama)." };
}
};
export const analyzeScheduleAndSuggest = async (
events: CalendarEvent[],
settings: AppSettings
): Promise<string[]> => {
if (!settings.ollamaUrl) return ["The oracle is disconnected."];
const eventsSummary = events.map(e =>
`- ${e.title} (${e.category}) on ${e.startTime.split('T')[0]}`
).join('\n');
const prompt = `
Analyze this couple's schedule:
${eventsSummary}
Based on these events, suggest 3 specific, thoughtful, or fun additional events they should add to balance their life.
Look for missing date nights, overwhelming work blocks, or chores that need doing.
Return ONLY a JSON array of 3 strings.
`;
try {
const response = await fetch(`${settings.ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollamaModel,
prompt: prompt,
stream: false,
format: "json"
})
});
if (!response.ok) return ["Plan a surprise dinner!", "Go for a moonlit walk.", "Cook a meal together."];
const data = await response.json();
const parsed = JSON.parse(data.response);
return Array.isArray(parsed) ? parsed : ["Plan a surprise dinner!", "Go for a moonlit walk.", "Cook a meal together."];
} catch (e) {
return ["Plan a surprise dinner!", "Go for a moonlit walk.", "Cook a meal together."];
}
};
export const generateDailyHoroscope = async (
momSign: string,
dadSign: string,
settings: AppSettings
): Promise<string> => {
if (!settings.ollamaUrl) return "Stars are silent today.";
const currentDate = new Date().toDateString();
const prompt = `
Generate a very short, mystical horoscope prediction (max 10 words) for a couple with signs ${momSign} and ${dadSign} for today, ${currentDate}.
Focus on their shared energy or love.
Return JSON: { "prediction": "string" }
`;
try {
const response = await fetch(`${settings.ollamaUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: settings.ollamaModel,
prompt: prompt,
stream: false,
format: "json"
})
});
if (!response.ok) return "Cosmic energy flows.";
const data = await response.json();
const parsed = JSON.parse(data.response);
return parsed.prediction || "Alignment looks favorable.";
} catch (e) {
console.error("Horoscope gen failed", e);
return "Magic is in the air.";
}
};

View File

@@ -0,0 +1,90 @@
import { CalendarEvent, UserRole, AppSettings } from '../types';
const EVENTS_KEY = 'ourtime_events_v1';
const SETTINGS_KEY = 'ourtime_settings_v1';
export const getStoredEvents = (): CalendarEvent[] => {
try {
const stored = localStorage.getItem(EVENTS_KEY);
return stored ? JSON.parse(stored) : [];
} catch (e) {
console.error("Failed to load events", e);
return [];
}
};
export const saveEvent = (event: CalendarEvent): CalendarEvent[] => {
const events = getStoredEvents();
// Check if update or new
const index = events.findIndex(e => e.id === event.id);
let newEvents;
if (index >= 0) {
newEvents = [...events];
newEvents[index] = event;
} else {
newEvents = [...events, event];
}
localStorage.setItem(EVENTS_KEY, JSON.stringify(newEvents));
return newEvents;
};
export const deleteEvent = (eventId: string): CalendarEvent[] => {
const events = getStoredEvents();
const newEvents = events.filter(e => e.id !== eventId);
localStorage.setItem(EVENTS_KEY, JSON.stringify(newEvents));
return newEvents;
};
export const getSettings = (): AppSettings => {
try {
const stored = localStorage.getItem(SETTINGS_KEY);
if (stored) return JSON.parse(stored);
} catch (e) {
console.error(e);
}
return {
momColor: '#F472B6',
dadColor: '#60A5FA',
mcpServerUrl: 'http://192.168.1.100:8000/stream',
enableMcp: false,
aiProvider: 'gemini', // Default to Gemini for reliability, user can switch to Ollama
ollamaUrl: 'http://localhost:11434',
ollamaModel: 'llama3',
momZodiac: 'Unknown',
dadZodiac: 'Unknown',
familiar: 'None'
};
};
export const saveSettings = (settings: AppSettings) => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
};
// Seed some initial data if empty for demo purposes
export const seedInitialData = () => {
if (!localStorage.getItem(EVENTS_KEY)) {
const today = new Date();
const initialEvents: CalendarEvent[] = [
{
id: '1',
title: 'Date Night ❤️',
startTime: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 2, 19, 0).toISOString(),
endTime: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 2, 21, 0).toISOString(),
createdBy: 'dad',
category: 'date',
description: 'Dinner at the Italian place',
},
{
id: '2',
title: 'Grocery Run',
startTime: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 10, 0).toISOString(),
endTime: new Date(today.getFullYear(), today.getMonth(), today.getDate(), 11, 0).toISOString(),
createdBy: 'mom',
category: 'chore',
description: 'Milk, Eggs, Bread',
}
];
localStorage.setItem(EVENTS_KEY, JSON.stringify(initialEvents));
}
};

29
tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

70
types.ts Normal file
View File

@@ -0,0 +1,70 @@
export type UserRole = 'mom' | 'dad';
export type EventCategory = 'general' | 'chore' | 'date' | 'work' | 'fun' | 'magic';
export type UserStatus = 'online' | 'busy' | 'sleeping' | 'casting_spells' | 'thinking' | 'offline';
export type ZodiacSign = 'Aries' | 'Taurus' | 'Gemini' | 'Cancer' | 'Leo' | 'Virgo' | 'Libra' | 'Scorpio' | 'Sagittarius' | 'Capricorn' | 'Aquarius' | 'Pisces' | 'Unknown';
export type Familiar = 'Cat' | 'Owl' | 'Toad' | 'Bat' | 'Crow' | 'Wolf' | 'None';
export type AIProvider = 'ollama' | 'gemini';
export interface User {
username: string;
role: UserRole;
avatarColor: string;
}
export interface Collaborator {
role: UserRole;
status: UserStatus;
lastActive: Date;
currentAction?: string;
}
export interface CalendarEvent {
id: string;
title: string;
description?: string;
startTime: string; // ISO string
endTime: string; // ISO string
createdBy: UserRole;
category: EventCategory;
isAllDay?: boolean;
color?: string;
location?: string;
}
export interface AppSettings {
momColor: string;
dadColor: string;
mcpServerUrl: string;
enableMcp: boolean;
aiProvider: AIProvider;
ollamaUrl: string;
ollamaModel: string;
momZodiac: ZodiacSign;
dadZodiac: ZodiacSign;
familiar: Familiar;
}
export interface PulseAnalysis {
harmonyScore: number;
vibe: string;
burnoutWarning: string | null;
upcomingConflicts: string[];
smartNudges: string[]; // Suggestions like "Text Mom good luck on her meeting"
}
export interface DreamEntry {
id: string;
date: string;
user: UserRole;
content: string;
interpretation: string;
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
isSystem?: boolean;
}

23
vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});