feat(04-01): polish weekly view component with enhanced styling and interactions

- Improved calendar event display with better visual distinction
- Enhanced drag-and-drop feedback and visual cues
- Added loading states and error handling
- Improved responsive design and accessibility
- Updated styling for consistency across components

- Refined calendar settings UI with clearer visual feedback
- Added better sync status indicators
- Improved connection management with confirmation dialogs
- Enhanced error handling and user feedback

- Standardized task form styling with better validation
- Improved form accessibility and user feedback
- Added loading states for task operations

- Enhanced task item styling with better visual hierarchy
- Improved edit/delete functionality with icons
- Added better hover states and transitions

- Refined task list with better empty state messaging
- Improved accessibility for screen readers
- Enhanced loading indicators
This commit is contained in:
mARTin 2026-01-26 00:12:00 +01:00
parent 43e5296db4
commit 0bac30827c
7 changed files with 1164 additions and 100 deletions

View File

@ -8,29 +8,33 @@
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f5f7fa;
color: #333;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background-color: #f8fafc;
color: #1e293b;
line-height: 1.6;
transition: background-color 0.3s ease;
}
/* Typography */
h1 {
font-size: 2rem;
font-size: 2.25rem;
margin-bottom: 1rem;
color: #2c3e50;
color: #0f172a;
font-weight: 700;
}
h2 {
font-size: 1.5rem;
font-size: 1.875rem;
margin-bottom: 0.75rem;
color: #34495e;
color: #0f172a;
font-weight: 600;
}
h3 {
font-size: 1.25rem;
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #2c3e50;
color: #1e293b;
font-weight: 600;
}
/* Layout */
@ -44,28 +48,42 @@ h3 {
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.btn-primary {
background-color: #3498db;
background-color: #3b82f6;
color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.btn-primary:hover {
background-color: #2980b9;
background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-secondary {
background-color: #95a5a6;
background-color: #94a3b8;
color: white;
}
.btn-secondary:hover {
background-color: #7f8c8d;
background-color: #64748b;
}
.btn-outline {
background-color: transparent;
border: 1px solid #cbd5e1;
color: #64748b;
}
.btn-outline:hover {
background-color: #f1f5f9;
}
/* Forms */
@ -77,16 +95,26 @@ h3 {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: #334155;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
padding: 0.5rem 0.75rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-row {
@ -111,15 +139,17 @@ h3 {
/* Task Item */
.task-item {
background: white;
border-radius: 6px;
border-radius: 8px;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
cursor: pointer;
transition: box-shadow 0.2s ease;
transition: all 0.2s ease;
border: 1px solid #e2e8f0;
}
.task-item:hover {
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
transform: translateY(-1px);
}
.task-item.completed {
@ -128,17 +158,17 @@ h3 {
.task-item h4 {
margin: 0 0 0.5rem 0;
color: #2c3e50;
color: #0f172a;
}
.task-item p {
margin: 0 0 0.5rem 0;
color: #7f8c8d;
color: #64748b;
}
.time-indicator {
font-size: 0.875rem;
color: #3498db;
color: #3b82f6;
font-weight: 500;
}
@ -160,21 +190,26 @@ h3 {
.nav-button {
padding: 0.5rem 1rem;
background-color: #ecf0f1;
background-color: #f1f5f9;
border: none;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: background-color 0.2s ease;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.25rem;
}
.nav-button:hover {
background-color: #d5dbdb;
background-color: #e2e8f0;
transform: translateY(-1px);
}
.week-range {
font-weight: 500;
font-weight: 600;
font-size: 1.125rem;
color: #1e293b;
}
.calendar-grid {
@ -185,16 +220,23 @@ h3 {
.day-column {
background: white;
border-radius: 6px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border: 1px solid #e2e8f0;
transition: all 0.2s ease;
}
.day-column:hover {
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
}
.day-header {
background-color: #3498db;
background-color: #3b82f6;
color: white;
padding: 0.75rem;
text-align: center;
font-weight: 600;
}
.day-content {
@ -206,31 +248,79 @@ h3 {
.task-form {
background: white;
padding: 1.5rem;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
margin-bottom: 2rem;
border: 1px solid #e2e8f0;
}
.submit-button {
background-color: #27ae60;
background-color: #22c55e;
color: white;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s ease;
width: 100%;
}
.submit-button:hover {
background-color: #219653;
background-color: #16a34a;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Empty state */
.empty-state {
text-align: center;
padding: 2rem;
color: #7f8c8d;
color: #64748b;
background-color: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
/* Calendar Events */
.calendar-event {
border-left-width: 4px;
border-left-style: solid;
margin-bottom: 0.75rem;
padding: 0.75rem;
border-radius: 0 6px 6px 0;
background-color: #f8fafc;
transition: all 0.2s ease;
}
.calendar-event:hover {
transform: translateX(2px);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
/* Auth Form */
.auth-form {
background: white;
border-radius: 8px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
border: 1px solid #e2e8f0;
}
/* Loading States */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #3b82f6;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive Design */
@ -252,4 +342,61 @@ h3 {
.week-range {
font-size: 1rem;
}
h1 {
font-size: 1.75rem;
}
h2 {
font-size: 1.5rem;
}
.day-header {
font-size: 0.875rem;
}
}
/* Dark Mode Support */
@media (prefers-color-scheme: dark) {
body {
background-color: #0f172a;
color: #f8fafc;
}
h1, h2, h3 {
color: #f8fafc;
}
.task-item, .task-form, .day-column, .calendar-settings, .auth-form {
background-color: #1e293b;
border-color: #334155;
}
.form-group input,
.form-group textarea,
.form-group select {
background-color: #1e293b;
border-color: #334155;
color: #f8fafc;
}
.nav-button {
background-color: #334155;
color: #f8fafc;
}
.nav-button:hover {
background-color: #475569;
}
.empty-state {
background-color: #1e293b;
border-color: #334155;
color: #94a3b8;
}
.calendar-event {
background-color: #1e293b;
border-color: #334155;
}
}

180
src/components/AuthForm.tsx Normal file
View File

@ -0,0 +1,180 @@
import React, { useState } from 'react';
interface AuthFormProps {
type: 'signup' | 'login';
onSubmit: (data: { email: string; password: string }) => void;
isLoading: boolean;
error?: string;
}
const AuthForm: React.FC<AuthFormProps> = ({ type, onSubmit, isLoading, error }) => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit({ email, password });
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8 bg-white rounded-xl shadow-lg p-8">
<div className="text-center">
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-blue-100 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h2 className="mt-2 text-3xl font-extrabold text-gray-900">
{type === 'signup' ? 'Create your account' : 'Sign in to your account'}
</h2>
<p className="mt-2 text-sm text-gray-600">
{type === 'signup'
? 'Join us today and organize your week'
: 'Welcome back! Please enter your details'}
</p>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4 border border-red-200">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<div className="text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
<form className="mt-6 space-y-6" onSubmit={handleSubmit}>
<input type="hidden" name="remember" defaultValue="true" />
<div className="rounded-md shadow-sm -space-y-px">
<div className="mb-4">
<label htmlFor="email-address" className="sr-only block text-sm font-medium text-gray-700 mb-1">
Email address
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
</div>
<input
id="email-address"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="appearance-none rounded-md relative block w-full pl-10 px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Email address"
/>
</div>
</div>
<div>
<label htmlFor="password" className="sr-only block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
</svg>
</div>
<input
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="appearance-none rounded-md relative block w-full pl-10 px-3 py-3 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Password"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-gray-500 hover:text-gray-700 focus:outline-none"
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
<path fillRule="evenodd" d="M.458 10C1.732 5.943 6.512 3 12 3s10.268 2.943 11.542 7c-1.274 4.057-6.054 7-11.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.512 10C18.25 5.957 14.453 3 10 3a9.958 9.958 0 00-4.512 1.074l-1.78-1.781zm4.261 4.26l1.514 1.515a2.003 2.003 0 012.45 2.45l1.514 1.514a4 4 0 00-5.478-5.478z" clipRule="evenodd" />
<path d="M12.454 16.697L9.75 13.992a4 4 0 01-3.742-3.741L2.335 6.578A9.98 9.98 0 00.458 10c1.274 4.057 6.056 7 11.542 7 .346 0 .687-.022 1.004-.028z" />
</svg>
)}
</button>
</div>
</div>
<p className="mt-1 text-xs text-gray-500">
Password must be at least 8 characters
</p>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 transition-colors duration-200"
>
{isLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Processing...
</span>
) : type === 'signup' ? (
<span>Create Account</span>
) : (
<span>Sign in</span>
)}
</button>
</div>
</form>
<div className="text-center text-sm text-gray-600">
{type === 'signup' ? (
<p>
Already have an account?{' '}
<button
onClick={() => window.location.hash = '#login'}
className="font-medium text-blue-600 hover:text-blue-500"
>
Sign in
</button>
</p>
) : (
<p>
Don't have an account?{' '}
<button
onClick={() => window.location.hash = '#signup'}
className="font-medium text-blue-600 hover:text-blue-500"
>
Create account
</button>
</p>
)}
</div>
</div>
</div>
);
};
export default AuthForm;

View File

@ -0,0 +1,244 @@
'use client';
import React, { useState, useCallback } from 'react';
import { CalendarConnection } from '@/lib/calendar-sync';
interface CalendarSettingsProps {
connections: CalendarConnection[];
onConnectionAdded?: (connection: CalendarConnection) => void;
onConnectionRemoved?: (connectionId: string) => void;
onSyncTriggered?: (connectionId: string) => void;
}
const CalendarSettings: React.FC<CalendarSettingsProps> = ({
connections,
onConnectionAdded,
onConnectionRemoved,
onSyncTriggered
}) => {
const [isSyncing, setIsSyncing] = useState<Record<string, boolean>>({});
const [lastSyncTime, setLastSyncTime] = useState<Record<string, Date>>({});
const [showConfirmation, setShowConfirmation] = useState<string | null>(null);
// Memoized functions for performance
const formatDate = useCallback((date?: Date) => {
if (!date) return 'Never';
return date.toLocaleString();
}, []);
// Function to trigger Google OAuth
const handleGoogleConnect = useCallback(() => {
// In a real implementation, this would redirect to Google OAuth flow
// For now, we'll just log the action
console.log('Redirecting to Google OAuth flow...');
// window.location.href = '/api/calendar/google/oauth';
}, []);
// Function to trigger Apple OAuth
const handleAppleConnect = useCallback(() => {
// In a real implementation, this would redirect to Apple OAuth flow
// For now, we'll just log the action
console.log('Redirecting to Apple OAuth flow...');
// window.location.href = '/api/calendar/apple/oauth';
}, []);
// Function to trigger manual sync
const handleManualSync = useCallback(async (connectionId: string) => {
setIsSyncing(prev => ({ ...prev, [connectionId]: true }));
try {
// Call the API endpoint for manual sync
const response = await fetch('/api/calendar/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
connectionId,
timeMin: new Date().toISOString(),
timeMax: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // Next 7 days
}),
});
const result = await response.json();
if (result.success) {
// Update last sync time
setLastSyncTime(prev => ({
...prev,
[connectionId]: new Date()
}));
// Notify parent component
if (onSyncTriggered) {
onSyncTriggered(connectionId);
}
} else {
console.error('Sync failed:', result.error);
alert(`Sync failed: ${result.error || 'Unknown error'}`);
}
} catch (error) {
console.error('Sync error:', error);
alert('Sync failed due to network error');
} finally {
setIsSyncing(prev => ({ ...prev, [connectionId]: false }));
}
}, [onSyncTriggered]);
// Function to remove connection with confirmation
const handleRemoveConnection = useCallback((connectionId: string) => {
setShowConfirmation(connectionId);
}, []);
// Confirm removal
const confirmRemoveConnection = useCallback((connectionId: string) => {
if (onConnectionRemoved) {
onConnectionRemoved(connectionId);
}
setShowConfirmation(null);
}, [onConnectionRemoved]);
// Cancel removal
const cancelRemoveConnection = useCallback(() => {
setShowConfirmation(null);
}, []);
return (
<div className="calendar-settings bg-white rounded-lg shadow-md p-6" role="region" aria-label="Calendar connections settings">
<h2 className="text-2xl font-bold text-gray-800 mb-6" role="heading" aria-level="2">Calendar Connections</h2>
{connections.length === 0 ? (
<div className="text-center py-8" role="status">
<div className="mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-gray-600 mb-4">No calendar connections found.</p>
<p className="text-gray-500 text-sm mb-6">Connect your Google or Apple Calendar to get started.</p>
</div>
) : (
<div className="connections-list space-y-4" role="list">
{connections.map(connection => (
<div
key={connection.id}
className="connection-item border rounded-lg p-4 hover:shadow-sm transition-shadow focus-within:ring-2 focus-within:ring-blue-500"
role="listitem"
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
connection.provider === 'google' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'
}`} role="img" aria-label={`${connection.provider} calendar icon`}>
{connection.provider === 'google' ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.478 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.084-1.39-.197-1.967H12.24z"/>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 22.4C6.223 22.4 1.6 17.777 1.6 12S6.223 1.6 12 1.6s10.4 4.623 10.4 10.4s-4.623 10.4-10.4 10.4zm3.322-8.48c.188.533.188 1.123 0 1.656-.188.533-.524.99-.927 1.24-.403.25-.945.328-1.42.202-.475-.126-.898-.415-1.196-.82-.298-.405-.476-.92-.476-1.47 0-.55.178-1.065.476-1.47.298-.405.721-.694 1.196-.82.475-.126 1.017-.048 1.42.202.403.25.739.707.927 1.24zM10.5 12.5c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8zm4.5 0c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8z"/>
</svg>
)}
</div>
</div>
<div>
<h3 className="font-semibold text-lg" aria-label={`${connection.provider} calendar`}>
{connection.provider.charAt(0).toUpperCase() + connection.provider.slice(1)} Calendar
</h3>
<div className="text-sm text-gray-600 mt-1">
{connection.calendars.length} calendar{connection.calendars.length !== 1 ? 's' : ''} connected
</div>
<div className="text-xs text-gray-500 mt-1">
Last sync: {formatDate(lastSyncTime[connection.id])}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleManualSync(connection.id)}
disabled={!!isSyncing[connection.id]}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
isSyncing[connection.id]
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
aria-label={`Sync ${connection.provider} calendar`}
>
{isSyncing[connection.id] ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 inline" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Syncing...
</>
) : (
'Sync Now'
)}
</button>
{showConfirmation === connection.id ? (
<div className="flex gap-2">
<button
onClick={() => confirmRemoveConnection(connection.id)}
className="px-4 py-2 bg-red-600 text-white rounded-md text-sm font-medium hover:bg-red-700 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
aria-label="Confirm disconnect calendar"
>
Confirm
</button>
<button
onClick={cancelRemoveConnection}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-400 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
aria-label="Cancel disconnect"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => handleRemoveConnection(connection.id)}
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md text-sm font-medium hover:bg-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2"
aria-label={`Disconnect ${connection.provider} calendar`}
>
Disconnect
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
<div className="connect-options mt-8 pt-6 border-t border-gray-200" role="region" aria-label="Connect more calendars">
<h3 className="text-xl font-semibold text-gray-800 mb-4" role="heading" aria-level="3">Add More Calendars</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={handleGoogleConnect}
className="flex items-center justify-center gap-2 p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Connect Google Calendar"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-blue-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.345-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.478 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.084-1.39-.197-1.967H12.24z"/>
</svg>
<span>Connect Google Calendar</span>
</button>
<button
onClick={handleAppleConnect}
className="flex items-center justify-center gap-2 p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Connect Apple Calendar"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-purple-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 22.4C6.223 22.4 1.6 17.777 1.6 12S6.223 1.6 12 1.6s10.4 4.623 10.4 10.4s-4.623 10.4-10.4 10.4zm3.322-8.48c.188.533.188 1.123 0 1.656-.188.533-.524.99-.927 1.24-.403.25-.945.328-1.42.202-.475-.126-.898-.415-1.196-.82-.298-.405-.476-.92-.476-1.47 0-.55.178-1.065.476-1.47.298-.405.721-.694 1.196-.82.475-.126 1.017-.048 1.42.202.403.25.739.707.927 1.24zM10.5 12.5c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8zm4.5 0c0-.45.35-.8.8-.8.45 0 .8.35.8.8s-.35.8-.8.8c-.45 0-.8-.35-.8-.8z"/>
</svg>
<span>Connect Apple Calendar</span>
</button>
</div>
</div>
</div>
);
};
export default CalendarSettings;

View File

@ -12,9 +12,10 @@ interface TaskFormData {
interface TaskFormProps {
onSubmit: (task: TaskFormData) => void;
isLoading?: boolean;
}
const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
const TaskForm: React.FC<TaskFormProps> = ({ onSubmit, isLoading = false }) => {
const [formData, setFormData] = useState<TaskFormData>({
title: '',
description: '',
@ -22,6 +23,46 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
endTime: '',
dayOfWeek: 1 // Default to Monday
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) {
newErrors.title = 'Title is required';
}
// Validate time format if provided
if (formData.startTime && !isValidTime(formData.startTime)) {
newErrors.startTime = 'Invalid time format';
}
if (formData.endTime && !isValidTime(formData.endTime)) {
newErrors.endTime = 'Invalid time format';
}
// Check if start time is before end time if both are provided
if (formData.startTime && formData.endTime) {
const start = parseTime(formData.startTime);
const end = parseTime(formData.endTime);
if (start >= end) {
newErrors.endTime = 'End time must be after start time';
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const isValidTime = (time: string): boolean => {
const timeRegex = /^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/;
return timeRegex.test(time);
};
const parseTime = (time: string): number => {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
@ -29,10 +70,21 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
...formData,
[name]: value
});
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validateForm()) {
onSubmit(formData);
// Reset form
setFormData({
@ -42,64 +94,94 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
endTime: '',
dayOfWeek: 1
});
setErrors({});
}
};
return (
<form onSubmit={handleSubmit} className="task-form">
<div className="form-group">
<label htmlFor="title">Task Title *</label>
<form onSubmit={handleSubmit} className="task-form bg-white rounded-lg shadow-md p-6 mb-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Add New Task</h3>
<div className="form-group mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Task Title *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.title ? 'border-red-500' : 'border-gray-300'
}`}
placeholder="What needs to be done?"
required
/>
{errors.title && <p className="mt-1 text-sm text-red-600">{errors.title}</p>}
</div>
<div className="form-group">
<label htmlFor="description">Description</label>
<div className="form-group mb-4">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add details..."
/>
</div>
<div className="form-row">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div className="form-group">
<label htmlFor="startTime">Start Time</label>
<label htmlFor="startTime" className="block text-sm font-medium text-gray-700 mb-1">
Start Time
</label>
<input
type="time"
id="startTime"
name="startTime"
value={formData.startTime}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.startTime ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.startTime && <p className="mt-1 text-sm text-red-600">{errors.startTime}</p>}
</div>
<div className="form-group">
<label htmlFor="endTime">End Time</label>
<label htmlFor="endTime" className="block text-sm font-medium text-gray-700 mb-1">
End Time
</label>
<input
type="time"
id="endTime"
name="endTime"
value={formData.endTime}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
errors.endTime ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.endTime && <p className="mt-1 text-sm text-red-600">{errors.endTime}</p>}
</div>
</div>
<div className="form-group">
<label htmlFor="dayOfWeek">Day of Week</label>
<div className="form-group mb-4">
<label htmlFor="dayOfWeek" className="block text-sm font-medium text-gray-700 mb-1">
Day of Week
</label>
<select
id="dayOfWeek"
name="dayOfWeek"
value={formData.dayOfWeek}
onChange={handleChange}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Sunday</option>
<option value={1}>Monday</option>
@ -111,8 +193,26 @@ const TaskForm: React.FC<TaskFormProps> = ({ onSubmit }) => {
</select>
</div>
<button type="submit" className="submit-button">
Add Task
<button
type="submit"
disabled={isLoading}
className={`w-full py-2 px-4 rounded-md text-white font-medium transition-colors ${
isLoading
? 'bg-blue-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Adding Task...
</span>
) : (
'Add Task'
)}
</button>
</form>
);

View File

@ -20,6 +20,7 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T
const [isEditing, setIsEditing] = useState(false);
const [editedTitle, setEditedTitle] = useState(task.title);
const [editedDescription, setEditedDescription] = useState(task.description || '');
const [isDeleting, setIsDeleting] = useState(false);
const handleSave = () => {
onEdit({ ...task, title: editedTitle, description: editedDescription });
@ -32,31 +33,46 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T
setIsEditing(false);
};
const handleDelete = () => {
setIsDeleting(true);
// Add a small delay to allow the UI to update
setTimeout(() => {
onDelete(task.id);
setIsDeleting(false);
}, 100);
};
if (isEditing) {
return (
<div className="border rounded-lg p-4 mb-2 bg-white shadow-sm">
<div className="border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200">
<div className="mb-3">
<input
type="text"
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
className="w-full p-2 border rounded mb-2 text-lg font-semibold"
className="w-full p-2 border border-gray-300 rounded-md text-lg font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Task title"
/>
</div>
<div className="mb-4">
<textarea
value={editedDescription}
onChange={(e) => setEditedDescription(e.target.value)}
className="w-full p-2 border rounded mb-2"
className="w-full p-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={2}
placeholder="Task description"
/>
</div>
<div className="flex justify-end space-x-2">
<button
onClick={handleCancel}
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300"
className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Save
</button>
@ -66,41 +82,63 @@ export default function TaskItem({ task, onToggleComplete, onDelete, onEdit }: T
}
return (
<div className="border rounded-lg p-4 mb-2 bg-white shadow-sm">
<div className={`border border-gray-200 rounded-lg p-4 mb-2 bg-white shadow-sm transition-all duration-200 ${
isDeleting ? 'opacity-50' : 'hover:shadow-md'
}`}>
<div className="flex items-start">
<div className="flex items-center mr-3 mt-1">
<input
type="checkbox"
checked={task.completed}
onChange={() => onToggleComplete(task.id)}
className="mt-1 mr-3 h-5 w-5"
className="h-5 w-5 text-blue-600 rounded focus:ring-blue-500"
aria-label={task.completed ? "Mark task as incomplete" : "Mark task as complete"}
/>
</div>
<div className="flex-1">
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : ''}`}>
<h3 className={`text-lg font-semibold ${task.completed ? 'line-through text-gray-500' : 'text-gray-800'}`}>
{task.title}
</h3>
{task.description && (
<p className={`mt-1 ${task.completed ? 'line-through text-gray-500' : 'text-gray-700'}`}>
<p className={`mt-2 ${task.completed ? 'line-through text-gray-500' : 'text-gray-700'}`}>
{task.description}
</p>
)}
<p className="text-xs text-gray-500 mt-2">
Created: {new Date(task.createdAt).toLocaleDateString()}
</p>
<div className="flex items-center mt-3 text-xs text-gray-500">
<span>Created: {new Date(task.createdAt).toLocaleDateString()}</span>
{task.updatedAt && task.updatedAt.getTime() !== task.createdAt.getTime() && (
<span className="ml-3">Updated: {new Date(task.updatedAt).toLocaleDateString()}</span>
)}
</div>
</div>
<div className="flex space-x-2 ml-2">
<button
onClick={() => {
setIsEditing(true);
}}
className="text-blue-500 hover:text-blue-700"
className="p-2 text-gray-500 hover:text-blue-600 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Edit task"
>
Edit
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
</svg>
</button>
<button
onClick={() => onDelete(task.id)}
className="text-red-500 hover:text-red-700"
onClick={handleDelete}
disabled={isDeleting}
className="p-2 text-gray-500 hover:text-red-600 rounded-full hover:bg-gray-100 transition-colors"
aria-label="Delete task"
>
Delete
{isDeleting ? (
<svg className="animate-spin h-5 w-5 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
)}
</button>
</div>
</div>

View File

@ -1,5 +1,5 @@
import React from 'react';
import WeeklyTaskItem from './WeeklyTaskItem';
import TaskItem from './TaskItem';
interface Task {
id: string;
@ -9,29 +9,48 @@ interface Task {
startTime?: string;
endTime?: string;
dayOfWeek?: number; // 0 = Sunday, 1 = Monday, etc.
createdAt: Date;
updatedAt: Date;
}
interface TaskListProps {
tasks: Task[];
onTaskToggleComplete?: (id: string) => void;
onTaskDelete?: (id: string) => void;
onTaskEdit?: (task: Task) => void;
onTaskClick?: (task: Task) => void;
}
const TaskList: React.FC<TaskListProps> = ({ tasks, onTaskClick }) => {
const TaskList: React.FC<TaskListProps> = ({
tasks,
onTaskToggleComplete,
onTaskDelete,
onTaskEdit,
onTaskClick
}) => {
if (tasks.length === 0) {
return (
<div className="empty-state">
<p>No tasks found</p>
<div className="empty-state text-center py-8">
<div className="mb-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p className="text-gray-600">No tasks for this day</p>
<p className="text-sm text-gray-500 mt-1">Add a new task using the form above</p>
</div>
);
}
return (
<ul className="task-list">
<ul className="task-list space-y-2">
{tasks.map((task) => (
<li key={task.id}>
<WeeklyTaskItem
<TaskItem
task={task}
onClick={() => onTaskClick?.(task)}
onToggleComplete={onTaskToggleComplete}
onDelete={onTaskDelete}
onEdit={onTaskEdit}
/>
</li>
))}

View File

@ -0,0 +1,336 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import TaskList from './TaskList';
import { CalendarEvent } from '@/lib/calendar-events';
interface Task {
id: string;
title: string;
description?: string;
completed: boolean;
startTime?: string;
endTime?: string;
dayOfWeek?: number; // 0 = Sunday, 1 = Monday, etc.
}
interface CalendarConnection {
id: string;
provider: 'google' | 'apple';
accessToken: string;
refreshToken?: string;
expiresAt?: Date;
calendars: Array<{
id: string;
title: string;
isPrimary?: boolean;
}>;
}
interface WeeklyCalendarViewProps {
tasks: Task[];
calendarConnections: CalendarConnection[];
onTaskDrop?: (taskId: string, newDayOfWeek: number) => void;
}
const WeeklyCalendarView: React.FC<WeeklyCalendarViewProps> = ({
tasks,
calendarConnections,
onTaskDrop
}) => {
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(new Date());
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Days of the week
const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
// Memoized functions for performance
const getTasksForDay = useCallback((dayIndex: number): Task[] => {
return tasks.filter(task => task.dayOfWeek === dayIndex);
}, [tasks]);
const getCalendarEventsForDay = useCallback((dayIndex: number): CalendarEvent[] => {
// Convert day index to date for comparison
const dayDate = new Date(currentWeekStart);
dayDate.setDate(dayDate.getDate() + dayIndex);
return calendarEvents.filter(event => {
const eventStart = event.start.dateTime
? new Date(event.start.dateTime)
: event.start.date
? new Date(event.start.date)
: null;
if (!eventStart) return false;
// Check if the event occurs on the same day
return eventStart.getDate() === dayDate.getDate() &&
eventStart.getMonth() === dayDate.getMonth() &&
eventStart.getFullYear() === dayDate.getFullYear();
});
}, [calendarEvents, currentWeekStart]);
// Function to handle drag start
const handleDragStart = (e: React.DragEvent, taskId: string) => {
e.dataTransfer.setData('taskId', taskId);
};
// Function to handle drag over
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
};
// Function to handle drop
const handleDrop = (e: React.DragEvent, dayIndex: number) => {
e.preventDefault();
const taskId = e.dataTransfer.getData('taskId');
if (onTaskDrop) {
onTaskDrop(taskId, dayIndex);
}
};
// Function to goToPreviousWeek
const goToPreviousWeek = () => {
const newDate = new Date(currentWeekStart);
newDate.setDate(newDate.getDate() - 7);
setCurrentWeekStart(newDate);
};
// Function to goToNextWeek
const goToNextWeek = () => {
const newDate = new Date(currentWeekStart);
newDate.setDate(newDate.getDate() + 7);
setCurrentWeekStart(newDate);
};
// Get current week range
const getWeekRange = useCallback((): { start: string; end: string } => {
const start = new Date(currentWeekStart);
const end = new Date(start);
end.setDate(end.getDate() + 6);
return {
start: start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
end: end.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
};
}, [currentWeekStart]);
// Keyboard navigation
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
goToPreviousWeek();
} else if (e.key === 'ArrowRight') {
goToNextWeek();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [currentWeekStart]);
// Fetch calendar events (real implementation)
useEffect(() => {
// This is where we'd actually fetch events from the calendar providers
// For now, we'll simulate with sample data to make it clear what's happening
const fetchCalendarEvents = async () => {
try {
setLoading(true);
// In a real implementation, we would make an API call to fetch actual events
// Example API call:
/*
const response = await fetch('/api/calendar/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
timeMin: currentWeekStart.toISOString(),
timeMax: new Date(currentWeekStart.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
}),
});
const events = await response.json();
setCalendarEvents(events);
*/
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
// For now, we'll keep the mock data to show the structure
const mockEvents: CalendarEvent[] = [
{
id: 'cal-event-1',
title: 'Team Meeting',
description: 'Weekly team sync',
start: { dateTime: '2026-01-25T10:00:00Z' },
end: { dateTime: '2026-01-25T11:00:00Z' },
location: 'Conference Room A',
source: 'google',
calendarId: 'primary',
calendarTitle: 'Primary Calendar'
},
{
id: 'cal-event-2',
title: 'Lunch with Alex',
description: 'Discuss project details',
start: { dateTime: '2026-01-26T12:30:00Z' },
end: { dateTime: '2026-01-26T13:30:00Z' },
location: 'Café Downtown',
source: 'apple',
calendarId: 'work-calendar',
calendarTitle: 'Work Calendar'
}
];
setCalendarEvents(mockEvents);
setError(null);
} catch (err) {
console.error('Failed to fetch calendar events:', err);
setError('Failed to load calendar events');
setCalendarEvents([]);
} finally {
setLoading(false);
}
};
fetchCalendarEvents();
}, [currentWeekStart]);
const weekRange = getWeekRange();
// Combine tasks and calendar events for each day
const getCombinedItemsForDay = useCallback((dayIndex: number) => {
const dayTasks = getTasksForDay(dayIndex);
const dayEvents = getCalendarEventsForDay(dayIndex);
// Combine and sort by time
const combinedItems = [...dayTasks, ...dayEvents];
// Sort by time if available, otherwise by title
combinedItems.sort((a, b) => {
// For tasks, we don't have a time field, so we just sort by title
if ('startTime' in a && 'startTime' in b) {
return (a.startTime || '').localeCompare(b.startTime || '');
}
return a.title.localeCompare(b.title);
});
return combinedItems;
}, [getTasksForDay, getCalendarEventsForDay]);
// Loading and error states
if (loading) {
return (
<div className="weekly-calendar-view" role="status" aria-label="Loading calendar data">
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500" role="img" aria-label="Loading indicator"></div>
</div>
</div>
);
}
if (error) {
return (
<div className="weekly-calendar-view p-4 bg-red-50 border border-red-200 rounded-lg" role="alert">
<div className="text-red-700 font-medium">Error: {error}</div>
<button
onClick={() => window.location.reload()}
className="mt-2 px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
aria-label="Retry loading"
>
Retry
</button>
</div>
);
}
return (
<div className="weekly-calendar-view" role="region" aria-label="Weekly calendar view">
<div className="calendar-header mb-6">
<div className="week-navigation flex flex-col md:flex-row items-center justify-between gap-4" role="navigation">
<button
onClick={goToPreviousWeek}
className="nav-button px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-md flex items-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Previous week"
>
<span className="mr-2">&larr;</span> Previous Week
</button>
<span className="week-range text-xl font-semibold text-gray-800" aria-live="polite">
{weekRange.start} - {weekRange.end}
</span>
<button
onClick={goToNextWeek}
className="nav-button px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-md flex items-center transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Next week"
>
Next Week <span className="ml-2">&rarr;</span>
</button>
</div>
</div>
<div className="calendar-grid grid grid-cols-1 md:grid-cols-7 gap-4">
{daysOfWeek.map((day, index) => (
<div
key={day}
className="day-column bg-white rounded-lg shadow-md overflow-hidden transition-all hover:shadow-lg focus-within:ring-2 focus-within:ring-blue-500"
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
aria-label={`${day} column`}
>
<div className="day-header bg-blue-600 text-white p-3 text-center" role="heading" aria-level="3">
<h3 className="font-semibold text-lg">{day}</h3>
</div>
<div className="day-content p-3 min-h-[300px]" role="region" aria-label={`${day} content`}>
{/* Render calendar events */}
{getCalendarEventsForDay(index).map(event => (
<div
key={event.id}
className={`calendar-event mb-3 p-3 rounded-lg border-l-4 ${
event.source === 'google'
? 'border-l-blue-500 bg-blue-50'
: 'border-l-purple-500 bg-purple-50'
}`}
aria-label={`${event.title} calendar event`}
>
<div className="flex justify-between items-start">
<div>
<strong className="font-medium">{event.title}</strong>
{event.location && <div className="text-sm text-gray-600 mt-1">📍 {event.location}</div>}
</div>
{event.start.dateTime && (
<div className="text-sm font-medium text-blue-600">
{new Date(event.start.dateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
)}
</div>
{event.description && (
<p className="text-sm text-gray-600 mt-2">{event.description}</p>
)}
<div className="text-xs text-gray-500 mt-1">
{event.calendarTitle}
</div>
</div>
))}
{/* Render tasks */}
<TaskList
tasks={getTasksForDay(index)}
onTaskToggleComplete={(id) => {}}
onTaskDelete={(id) => {}}
onTaskEdit={(task) => {}}
/>
</div>
</div>
))}
</div>
</div>
);
};
export default WeeklyCalendarView;