more roadmap features
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s

This commit is contained in:
jason
2026-03-30 14:34:10 -05:00
parent ba2a76f7dd
commit a0c1ae9703
7 changed files with 1855 additions and 10 deletions

View File

@@ -1,3 +1,474 @@
export default function BoardPage() { import { useState } from 'react';
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Message Board</h1><p className="text-secondary mt-2">Phase 4</p></div>; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { MessageSquare, Pin, PinOff, Trash2, Plus } from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { api, type Message } from '@/lib/api';
import { useMembers } from '@/hooks/useMembers';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Textarea';
import { Modal } from '@/components/ui/Modal';
// ── Constants ──────────────────────────────────────────────────────────────
const NOTE_COLORS = ['#fef08a', '#bbf7d0', '#bfdbfe', '#fbcfe8', '#fed7aa', '#e9d5ff'];
const EXPIRY_OPTIONS = [
{ label: 'No expiry', value: '' },
{ label: '1 day', value: '1d' },
{ label: '3 days', value: '3d' },
{ label: '1 week', value: '1w' },
{ label: '2 weeks', value: '2w' },
{ label: '1 month', value: '1m' },
];
function calcExpiry(option: string): string | null {
if (!option) return null;
const d = new Date();
if (option === '1d') d.setDate(d.getDate() + 1);
else if (option === '3d') d.setDate(d.getDate() + 3);
else if (option === '1w') d.setDate(d.getDate() + 7);
else if (option === '2w') d.setDate(d.getDate() + 14);
else if (option === '1m') d.setMonth(d.getMonth() + 1);
return d.toISOString().replace('T', ' ').slice(0, 19);
}
// ── Mutation types ─────────────────────────────────────────────────────────
interface MsgCreate {
body: string;
color: string;
emoji: string | null;
member_id: number | null;
pinned: boolean;
expires_at: string | null;
}
interface MsgPatch {
id: number;
body?: string;
color?: string;
emoji?: string | null;
member_id?: number | null;
pinned?: boolean;
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function BoardPage() {
const qc = useQueryClient();
const { data: members = [] } = useMembers();
// Modal state
const [composeOpen, setComposeOpen] = useState(false);
const [editMessage, setEditMessage] = useState<Message | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Message | null>(null);
// Form fields
const [body, setBody] = useState('');
const [color, setColor] = useState(NOTE_COLORS[0]);
const [emoji, setEmoji] = useState('');
const [memberId, setMemberId] = useState<number | null>(null);
const [pinned, setPinned] = useState(false);
const [expiryOption, setExpiryOption] = useState('');
// ── Query ────────────────────────────────────────────────────────
const { data: messages = [], isLoading } = useQuery<Message[]>({
queryKey: ['messages'],
queryFn: () => api.get('/messages').then((r) => r.data),
});
const pinnedMessages = messages.filter((m) => !!m.pinned);
const regularMessages = messages.filter((m) => !m.pinned);
// ── Mutations ────────────────────────────────────────────────────
const createMutation = useMutation({
mutationFn: (data: MsgCreate) => api.post('/messages', data).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['messages'] });
closeCompose();
},
});
const patchMutation = useMutation({
mutationFn: ({ id, ...data }: MsgPatch) =>
api.patch(`/messages/${id}`, data).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['messages'] });
closeCompose();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/messages/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['messages'] });
setDeleteTarget(null);
},
});
// ── Handlers ─────────────────────────────────────────────────────
function openCompose() {
setEditMessage(null);
setBody('');
setColor(NOTE_COLORS[0]);
setEmoji('');
setMemberId(null);
setPinned(false);
setExpiryOption('');
setComposeOpen(true);
}
function openEdit(msg: Message) {
setEditMessage(msg);
setBody(msg.body);
setColor(msg.color);
setEmoji(msg.emoji ?? '');
setMemberId(msg.member_id);
setPinned(!!msg.pinned);
setExpiryOption('');
setComposeOpen(true);
}
function closeCompose() {
setComposeOpen(false);
setEditMessage(null);
}
function handleSave() {
if (!body.trim()) return;
if (editMessage) {
const patch: MsgPatch = {
id: editMessage.id,
body: body.trim(),
color,
emoji: emoji.trim() || null,
member_id: memberId,
pinned,
};
patchMutation.mutate(patch);
} else {
const payload: MsgCreate = {
body: body.trim(),
color,
emoji: emoji.trim() || null,
member_id: memberId,
pinned,
expires_at: calcExpiry(expiryOption),
};
createMutation.mutate(payload);
}
}
const isSaving = createMutation.isPending || patchMutation.isPending;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── Header ────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
<div className="flex items-center gap-3">
<MessageSquare size={22} className="text-accent" />
<div>
<h1 className="text-xl font-bold text-primary leading-tight">Message Board</h1>
<p className="text-xs text-muted">
{messages.length} note{messages.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<Button size="sm" onClick={openCompose}>
<Plus size={15} /> New Note
</Button>
</div>
{/* ── Content ───────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{isLoading ? (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-40 rounded-2xl bg-surface-raised animate-pulse" />
))}
</div>
) : messages.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 text-center"
>
<div className="text-5xl mb-4">📌</div>
<p className="text-lg font-semibold text-primary mb-1">No notes yet</p>
<p className="text-secondary text-sm mb-6">
Leave a message for the family to see.
</p>
<Button onClick={openCompose}>
<Plus size={16} /> Create Note
</Button>
</motion.div>
) : (
<>
{/* Pinned section */}
{pinnedMessages.length > 0 && (
<section>
<div className="flex items-center gap-1.5 mb-3">
<Pin size={12} className="text-muted" />
<span className="text-xs font-semibold text-muted uppercase tracking-wide">
Pinned
</span>
</div>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<AnimatePresence>
{pinnedMessages.map((msg) => (
<NoteCard
key={msg.id}
message={msg}
onEdit={() => openEdit(msg)}
onDelete={() => setDeleteTarget(msg)}
onTogglePin={() =>
patchMutation.mutate({ id: msg.id, pinned: !msg.pinned })
}
/>
))}
</AnimatePresence>
</div>
</section>
)}
{/* Regular section */}
{regularMessages.length > 0 && (
<section>
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<AnimatePresence>
{regularMessages.map((msg) => (
<NoteCard
key={msg.id}
message={msg}
onEdit={() => openEdit(msg)}
onDelete={() => setDeleteTarget(msg)}
onTogglePin={() =>
patchMutation.mutate({ id: msg.id, pinned: !msg.pinned })
}
/>
))}
</AnimatePresence>
</div>
</section>
)}
</>
)}
</div>
{/* ── Compose / Edit Modal ─────────────────────────────────── */}
<Modal
open={composeOpen}
onClose={closeCompose}
title={editMessage ? 'Edit Note' : 'New Note'}
size="sm"
>
<div className="space-y-4">
{/* Colour swatches */}
<div>
<p className="text-sm font-medium text-secondary mb-2">Colour</p>
<div className="flex items-center gap-2 flex-wrap">
{NOTE_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full border-2 transition-transform ${
color === c
? 'border-accent scale-110'
: 'border-transparent hover:scale-105'
}`}
style={{ backgroundColor: c }}
aria-label={`Select colour ${c}`}
/>
))}
</div>
</div>
{/* Emoji */}
<div>
<label className="text-sm font-medium text-secondary block mb-1.5">
Emoji (optional)
</label>
<input
type="text"
value={emoji}
onChange={(e) => setEmoji(e.target.value)}
placeholder="e.g. 🎉"
maxLength={4}
className="w-20 rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
/>
</div>
{/* Body */}
<Textarea
label="Message"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your note…"
rows={4}
autoFocus
/>
{/* Member */}
<div>
<label className="text-sm font-medium text-secondary block mb-1.5">
From (optional)
</label>
<select
value={memberId ?? ''}
onChange={(e) =>
setMemberId(e.target.value ? Number(e.target.value) : null)
}
className="w-full rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary focus:outline-none focus:ring-2 ring-accent transition-colors"
>
<option value="">No attribution</option>
{members.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</div>
{/* Pin + Expiry (expiry only for new notes) */}
<div className="flex items-center gap-6 flex-wrap">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={pinned}
onChange={(e) => setPinned(e.target.checked)}
className="rounded accent-accent"
/>
<span className="text-sm text-secondary">Pin note</span>
</label>
{!editMessage && (
<div className="flex items-center gap-2">
<label className="text-sm text-secondary whitespace-nowrap">Expires:</label>
<select
value={expiryOption}
onChange={(e) => setExpiryOption(e.target.value)}
className="rounded-lg border border-theme bg-surface-raised px-2 py-1.5 text-sm text-primary focus:outline-none focus:ring-2 ring-accent transition-colors"
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
)}
</div>
<div className="flex gap-2 justify-end pt-1">
<Button variant="secondary" onClick={closeCompose}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!body.trim()} loading={isSaving}>
Save
</Button>
</div>
</div>
</Modal>
{/* ── Delete Confirm Modal ─────────────────────────────────── */}
<Modal
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete Note?"
size="sm"
>
<div className="space-y-4">
<p className="text-secondary text-sm">
This will permanently delete this note. This cannot be undone.
</p>
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="danger"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Delete
</Button>
</div>
</div>
</Modal>
</div>
);
}
// ── NoteCard ───────────────────────────────────────────────────────────────
interface NoteCardProps {
message: Message;
onEdit: () => void;
onDelete: () => void;
onTogglePin: () => void;
}
function NoteCard({ message, onEdit, onDelete, onTogglePin }: NoteCardProps) {
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2 }}
className="relative group rounded-2xl p-4 cursor-pointer shadow-sm hover:shadow-md transition-shadow"
style={{ backgroundColor: message.color }}
onClick={onEdit}
>
{/* Hover action buttons */}
<div className="absolute top-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onTogglePin();
}}
className="p-1.5 rounded-lg bg-black/10 hover:bg-black/20 text-gray-900 transition-colors"
aria-label={!!message.pinned ? 'Unpin' : 'Pin'}
>
{!!message.pinned ? <PinOff size={13} /> : <Pin size={13} />}
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1.5 rounded-lg bg-black/10 hover:bg-black/20 text-gray-900 transition-colors"
aria-label="Delete note"
>
<Trash2 size={13} />
</button>
</div>
{/* Emoji */}
{message.emoji && (
<div className="text-3xl mb-2 leading-none">{message.emoji}</div>
)}
{/* Body */}
<p className="text-sm text-gray-900 leading-relaxed line-clamp-6 whitespace-pre-wrap">
{message.body}
</p>
{/* Footer */}
<div className="mt-3 flex items-center justify-between gap-2 flex-wrap">
<div className="text-xs text-gray-900/70">
{message.member_name && (
<span className="font-medium">{message.member_name} · </span>
)}
<span>{formatDistanceToNow(new Date(message.created_at), { addSuffix: true })}</span>
</div>
{message.expires_at && (
<span className="text-xs text-gray-900/60">
Expires {format(new Date(message.expires_at), 'MMM d')}
</span>
)}
</div>
</motion.div>
);
} }

View File

@@ -1,3 +1,407 @@
export default function CountdownsPage() { import { useState } from 'react';
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Countdowns</h1><p className="text-secondary mt-2">Phase 4</p></div>; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import { Timer, Plus, Pencil, Trash2 } from 'lucide-react';
import { format, differenceInCalendarDays } from 'date-fns';
import { api, type Countdown } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
// ── Constants ──────────────────────────────────────────────────────────────
const CD_COLORS = [
'#6366f1',
'#14b8a6',
'#f43f5e',
'#f59e0b',
'#8b5cf6',
'#10b981',
'#3b82f6',
'#ec4899',
];
// ── Mutation types ─────────────────────────────────────────────────────────
interface CdCreate {
title: string;
target_date: string;
emoji: string | null;
color: string;
show_on_dashboard: boolean;
}
interface CdPatch {
id: number;
title?: string;
target_date?: string;
emoji?: string | null;
color?: string;
show_on_dashboard?: boolean;
}
// ── Page ───────────────────────────────────────────────────────────────────
const today = format(new Date(), 'yyyy-MM-dd');
export default function CountdownsPage() {
const qc = useQueryClient();
// Modal state
const [formOpen, setFormOpen] = useState(false);
const [editCountdown, setEditCountdown] = useState<Countdown | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Countdown | null>(null);
// Form fields
const [title, setTitle] = useState('');
const [targetDate, setTargetDate] = useState('');
const [emoji, setEmoji] = useState('');
const [color, setColor] = useState(CD_COLORS[0]);
const [showOnDashboard, setShowOnDashboard] = useState(false);
// ── Query ────────────────────────────────────────────────────────
const { data: countdowns = [], isLoading } = useQuery<Countdown[]>({
queryKey: ['countdowns'],
queryFn: () => api.get('/countdowns').then((r) => r.data),
});
// ── Mutations ────────────────────────────────────────────────────
const createMutation = useMutation({
mutationFn: (data: CdCreate) => api.post('/countdowns', data).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['countdowns'] });
closeForm();
},
});
const updateMutation = useMutation({
mutationFn: ({ id, ...data }: CdPatch) =>
api.put(`/countdowns/${id}`, data).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['countdowns'] });
closeForm();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.delete(`/countdowns/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['countdowns'] });
setDeleteTarget(null);
},
});
// ── Handlers ─────────────────────────────────────────────────────
function openCreate() {
setEditCountdown(null);
setTitle('');
setTargetDate('');
setEmoji('');
setColor(CD_COLORS[0]);
setShowOnDashboard(false);
setFormOpen(true);
}
function openEdit(cd: Countdown) {
setEditCountdown(cd);
setTitle(cd.title);
setTargetDate(cd.target_date);
setEmoji(cd.emoji ?? '');
setColor(cd.color);
setShowOnDashboard(!!cd.show_on_dashboard);
setFormOpen(true);
}
function closeForm() {
setFormOpen(false);
setEditCountdown(null);
}
function handleSave() {
if (!title.trim() || !targetDate) return;
if (editCountdown) {
const patch: CdPatch = {
id: editCountdown.id,
title: title.trim(),
target_date: targetDate,
emoji: emoji.trim() || null,
color,
show_on_dashboard: showOnDashboard,
};
updateMutation.mutate(patch);
} else {
const payload: CdCreate = {
title: title.trim(),
target_date: targetDate,
emoji: emoji.trim() || null,
color,
show_on_dashboard: showOnDashboard,
};
createMutation.mutate(payload);
}
}
const isSaving = createMutation.isPending || updateMutation.isPending;
const canSave = title.trim().length > 0 && targetDate.length > 0;
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── Header ────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0">
<div className="flex items-center gap-3">
<Timer size={22} className="text-accent" />
<div>
<h1 className="text-xl font-bold text-primary leading-tight">Countdowns</h1>
<p className="text-xs text-muted">
{countdowns.length} event{countdowns.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<Button size="sm" onClick={openCreate}>
<Plus size={15} /> New Countdown
</Button>
</div>
{/* ── Grid ──────────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto p-6">
{isLoading ? (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-44 rounded-2xl bg-surface-raised animate-pulse" />
))}
</div>
) : countdowns.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 text-center"
>
<div className="text-5xl mb-4"></div>
<p className="text-lg font-semibold text-primary mb-1">No countdowns yet</p>
<p className="text-secondary text-sm mb-6">
Track upcoming events and milestones.
</p>
<Button onClick={openCreate}>
<Plus size={16} /> Add Countdown
</Button>
</motion.div>
) : (
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
<AnimatePresence>
{countdowns.map((cd, index) => {
const daysLeft = differenceInCalendarDays(
new Date(cd.target_date + 'T00:00:00'),
new Date()
);
return (
<CountdownCard
key={cd.id}
countdown={cd}
daysLeft={daysLeft}
index={index}
onEdit={() => openEdit(cd)}
onDelete={() => setDeleteTarget(cd)}
/>
);
})}
</AnimatePresence>
</div>
)}
</div>
{/* ── Create / Edit Modal ──────────────────────────────────── */}
<Modal
open={formOpen}
onClose={closeForm}
title={editCountdown ? 'Edit Countdown' : 'New Countdown'}
size="sm"
>
<div className="space-y-4">
{/* Title */}
<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Summer Holiday"
autoFocus
/>
{/* Date + Emoji in 2-col grid */}
<div className="grid grid-cols-2 gap-3">
<Input
label="Target Date"
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
min={today}
/>
<div>
<label className="text-sm font-medium text-secondary block mb-1.5">
Emoji (optional)
</label>
<input
type="text"
value={emoji}
onChange={(e) => setEmoji(e.target.value)}
placeholder="e.g. 🎄"
maxLength={4}
className="w-full rounded-lg border border-theme bg-surface-raised px-3 py-2 text-sm text-primary placeholder:text-muted focus:outline-none focus:ring-2 ring-accent transition-colors"
/>
</div>
</div>
{/* Colour swatches */}
<div>
<p className="text-sm font-medium text-secondary mb-2">Colour</p>
<div className="flex items-center gap-2 flex-wrap">
{CD_COLORS.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full border-2 transition-transform ${
color === c
? 'border-accent scale-110'
: 'border-transparent hover:scale-105'
}`}
style={{ backgroundColor: c }}
aria-label={`Select colour ${c}`}
/>
))}
</div>
</div>
{/* Show on dashboard */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showOnDashboard}
onChange={(e) => setShowOnDashboard(e.target.checked)}
className="rounded accent-accent"
/>
<span className="text-sm text-secondary">Show on Dashboard</span>
</label>
<div className="flex gap-2 justify-end pt-1">
<Button variant="secondary" onClick={closeForm}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!canSave} loading={isSaving}>
Save
</Button>
</div>
</div>
</Modal>
{/* ── Delete Confirm Modal ─────────────────────────────────── */}
<Modal
open={!!deleteTarget}
onClose={() => setDeleteTarget(null)}
title="Delete Countdown?"
size="sm"
>
<div className="space-y-4">
<p className="text-secondary text-sm">
This will permanently delete{' '}
<strong className="text-primary">{deleteTarget?.title}</strong>. This cannot be
undone.
</p>
<div className="flex gap-2 justify-end">
<Button variant="secondary" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="danger"
loading={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
Delete
</Button>
</div>
</div>
</Modal>
</div>
);
}
// ── CountdownCard ──────────────────────────────────────────────────────────
interface CountdownCardProps {
countdown: Countdown;
daysLeft: number;
index: number;
onEdit: () => void;
onDelete: () => void;
}
function CountdownCard({ countdown: cd, daysLeft, index, onEdit, onDelete }: CountdownCardProps) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.2, delay: index * 0.04 }}
className="group relative rounded-2xl border border-theme bg-surface overflow-hidden shadow-sm hover:shadow-md transition-shadow"
>
{/* Top colour strip */}
<div className="h-1.5 w-full" style={{ backgroundColor: cd.color }} />
{/* Card body */}
<div className="p-4 flex flex-col items-center text-center gap-1">
{cd.emoji && <div className="text-3xl leading-none mb-1">{cd.emoji}</div>}
{/* Day number */}
<span
className="text-5xl font-bold tabular-nums leading-none"
style={{ color: cd.color }}
>
{daysLeft}
</span>
{/* Label */}
<span className="text-xs text-secondary">
{daysLeft === 1 ? 'day away' : 'days away'}
</span>
{/* Title */}
<p className="text-sm font-semibold text-primary mt-1 line-clamp-2">{cd.title}</p>
{/* Date */}
<p className="text-xs text-muted">
{format(new Date(cd.target_date + 'T00:00:00'), 'MMM d, yyyy')}
</p>
{/* Dashboard badge */}
{!!cd.show_on_dashboard && (
<span className="mt-1 px-2 py-0.5 rounded-full bg-surface-raised border border-theme text-xs text-muted">
On dashboard
</span>
)}
</div>
{/* Hover actions */}
<div className="absolute top-3 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="p-1.5 rounded-lg bg-surface-raised border border-theme text-secondary hover:text-primary transition-colors"
aria-label="Edit countdown"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1.5 rounded-lg bg-surface-raised border border-theme text-secondary hover:text-red-500 transition-colors"
aria-label="Delete countdown"
>
<Trash2 size={13} />
</button>
</div>
</motion.div>
);
} }

View File

@@ -1,8 +1,410 @@
export default function Dashboard() { import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { format, differenceInCalendarDays, isToday, isTomorrow } from 'date-fns';
import { Link } from 'react-router-dom';
import { motion } from 'framer-motion';
import { api, type AppSettings, type CalendarEvent, type Meal, type Message, type Countdown, type Chore } from '@/lib/api';
// ── Types ──────────────────────────────────────────────────────────────────
interface EventWithMember extends CalendarEvent {
member_name: string | null;
member_color: string | null;
}
interface ChoreWithMember extends Chore {
member_name: string | null;
member_color: string | null;
}
interface DashboardData {
meal_today: Meal | null;
upcoming_events: EventWithMember[];
pending_chores: ChoreWithMember[];
shopping_unchecked: number;
pinned_messages: Message[];
countdowns: Countdown[];
}
interface WeatherData {
configured: boolean;
error?: string;
city?: string;
temp?: number;
feels_like?: number;
humidity?: number;
description?: string;
icon?: string;
units?: string;
}
// ── Helpers ────────────────────────────────────────────────────────────────
function getGreeting(): string {
const h = new Date().getHours();
if (h >= 5 && h < 12) return 'Good morning';
if (h >= 12 && h < 17) return 'Good afternoon';
if (h >= 17 && h < 21) return 'Good evening';
return 'Good night';
}
function eventDateLabel(event: CalendarEvent): string {
const start = new Date(event.start_at);
if (isToday(start)) return 'Today';
if (isTomorrow(start)) return 'Tomorrow';
return format(start, 'EEE, MMM d');
}
function eventTimeLabel(event: CalendarEvent): string {
if (event.all_day) return 'All day';
return format(new Date(event.start_at), 'h:mm a');
}
function tempUnit(units?: string): string {
if (units === 'metric') return '°C';
if (units === 'standard') return 'K';
return '°F';
}
// ── Animation variants ─────────────────────────────────────────────────────
const fade = { hidden: { opacity: 0, y: 10 }, show: { opacity: 1, y: 0 } };
const stagger = { show: { transition: { staggerChildren: 0.07 } } };
// ── Card wrapper ───────────────────────────────────────────────────────────
const cardCls = 'bg-surface rounded-2xl shadow-sm border border-theme p-5';
// ── Widget header ──────────────────────────────────────────────────────────
function WidgetHeader({ emoji, label, to }: { emoji: string; label: string; to: string }) {
return ( return (
<div className="p-6"> <div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-bold text-primary mb-2">Good morning 👋</h1> <h2 className="font-semibold text-primary flex items-center gap-2">
<p className="text-secondary">Your family dashboard is coming together. More widgets arriving in Phase 2.</p> <span>{emoji}</span> {label}
</h2>
<Link to={to} className="text-xs text-accent hover:underline">
View all
</Link>
</div>
);
}
// ── Component ──────────────────────────────────────────────────────────────
export default function Dashboard() {
const [clock, setClock] = useState(new Date());
// Live clock
useEffect(() => {
const t = setInterval(() => setClock(new Date()), 1000);
return () => clearInterval(t);
}, []);
const { data: settings } = useQuery<AppSettings>({
queryKey: ['settings'],
queryFn: () => api.get('/settings').then((r) => r.data),
staleTime: 60_000,
});
const { data: dashboard } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: () => api.get('/dashboard').then((r) => r.data),
refetchInterval: 60_000,
});
const { data: weather } = useQuery<WeatherData>({
queryKey: ['weather'],
queryFn: () => api.get('/weather').then((r) => r.data),
staleTime: 10 * 60_000,
retry: false,
});
// ── Derived values ─────────────────────────────────────────────────────
const tf = settings?.time_format ?? '12h';
const timeStr = tf === '24h' ? format(clock, 'H:mm') : format(clock, 'h:mm');
const period = tf === '12h' ? format(clock, 'a') : '';
const dateStr = format(clock, 'EEEE, MMMM d, yyyy');
const greeting = getGreeting();
const countdownsToShow = (dashboard?.countdowns ?? []).filter((cd) => {
const days = differenceInCalendarDays(
new Date(cd.target_date + 'T00:00:00'),
new Date(),
);
return days >= 0;
});
// ── Render ─────────────────────────────────────────────────────────────
return (
<div className="p-4 sm:p-6 max-w-7xl mx-auto space-y-6">
{/* ── Hero: greeting + clock + weather ───────────────────────────── */}
<motion.div
className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-4"
variants={fade} initial="hidden" animate="show"
transition={{ duration: 0.4 }}
>
{/* Greeting + clock */}
<div>
<p className="text-xl font-semibold text-secondary">{greeting} 👋</p>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-6xl font-thin text-primary tabular-nums leading-none">
{timeStr}
</span>
{period && (
<span className="text-2xl text-secondary font-light leading-none">{period}</span>
)}
</div>
<p className="text-secondary mt-2 text-sm">{dateStr}</p>
</div>
{/* Weather card (shown only when configured and no error) */}
{weather?.configured && !weather.error && weather.temp !== undefined && (
<div className={`${cardCls} flex items-center gap-4`}>
{weather.icon && (
<img
src={`https://openweathermap.org/img/wn/${weather.icon}@2x.png`}
alt={weather.description ?? 'weather'}
className="w-16 h-16 flex-shrink-0"
/>
)}
<div>
<p className="text-4xl font-light text-primary leading-none">
{weather.temp}{tempUnit(weather.units)}
</p>
<p className="text-sm text-secondary capitalize mt-0.5">{weather.description}</p>
<p className="text-xs text-muted mt-1">
{weather.city}
{weather.humidity !== undefined && ` · ${weather.humidity}% humidity`}
{weather.feels_like !== undefined && ` · Feels ${weather.feels_like}${tempUnit(weather.units)}`}
</p>
</div>
</div>
)}
{/* Weather not configured nudge (subtle) */}
{weather && !weather.configured && (
<p className="text-xs text-muted self-end">
<Link to="/settings" className="hover:underline text-accent">Configure weather </Link>
</p>
)}
</motion.div>
{/* ── Main widget grid ───────────────────────────────────────────── */}
<motion.div
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4"
variants={stagger} initial="hidden" animate="show"
>
{/* ── Today's Dinner ── */}
<motion.div variants={fade} className={cardCls}>
<WidgetHeader emoji="🍽️" label="Tonight's Dinner" to="/meals" />
{dashboard?.meal_today ? (
<div className="space-y-1">
<p className="font-medium text-primary text-lg leading-snug">
{dashboard.meal_today.title}
</p>
{dashboard.meal_today.description && (
<p className="text-sm text-secondary">{dashboard.meal_today.description}</p>
)}
{dashboard.meal_today.recipe_url && (
<a
href={dashboard.meal_today.recipe_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-accent hover:underline block pt-1"
>
View recipe
</a>
)}
</div>
) : (
<p className="text-secondary text-sm">No dinner planned for today.</p>
)}
</motion.div>
{/* ── Upcoming Events ── */}
<motion.div variants={fade} className={`${cardCls} md:col-span-1 xl:col-span-2`}>
<WidgetHeader emoji="📅" label="Upcoming Events" to="/calendar" />
{dashboard?.upcoming_events?.length ? (
<ul className="space-y-0">
{dashboard.upcoming_events.slice(0, 5).map((event) => (
<li
key={event.id}
className="flex items-center gap-3 py-2 border-b border-theme last:border-0"
>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ background: event.color ?? event.member_color ?? 'var(--color-accent)' }}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-primary truncate">{event.title}</p>
{event.member_name && (
<p className="text-xs text-secondary">{event.member_name}</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="text-xs font-medium text-secondary">{eventDateLabel(event)}</p>
<p className="text-xs text-muted">{eventTimeLabel(event)}</p>
</div>
</li>
))}
</ul>
) : (
<p className="text-secondary text-sm">No events in the next 7 days. 🎉</p>
)}
</motion.div>
{/* ── Chores ── */}
<motion.div variants={fade} className={cardCls}>
<WidgetHeader emoji="✅" label="Chores" to="/chores" />
{dashboard ? (
dashboard.pending_chores.length === 0 ? (
<div className="text-center py-4">
<p className="text-3xl mb-1">🎉</p>
<p className="text-sm text-secondary">All chores are done!</p>
</div>
) : (
<>
<p className="text-3xl font-light text-primary tabular-nums leading-none mb-3">
{dashboard.pending_chores.length}
<span className="text-sm text-secondary font-normal ml-2">pending</span>
</p>
<ul className="space-y-1.5">
{dashboard.pending_chores.slice(0, 4).map((chore) => (
<li key={chore.id} className="flex items-center gap-2">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ background: chore.member_color ?? 'var(--color-text-muted)' }}
/>
<span className="text-sm text-primary truncate flex-1">{chore.title}</span>
{chore.member_name && (
<span className="text-xs text-muted flex-shrink-0">{chore.member_name}</span>
)}
</li>
))}
{dashboard.pending_chores.length > 4 && (
<li className="text-xs text-muted pl-4">
+{dashboard.pending_chores.length - 4} more
</li>
)}
</ul>
</>
)
) : (
<p className="text-secondary text-sm">Loading</p>
)}
</motion.div>
{/* ── Shopping ── */}
<motion.div variants={fade} className={cardCls}>
<WidgetHeader emoji="🛒" label="Shopping" to="/shopping" />
{dashboard !== undefined ? (
dashboard.shopping_unchecked === 0 ? (
<div className="text-center py-4">
<p className="text-3xl mb-1"></p>
<p className="text-sm text-secondary">Shopping list is clear!</p>
</div>
) : (
<div>
<p className="text-3xl font-light text-primary tabular-nums leading-none">
{dashboard.shopping_unchecked}
<span className="text-sm text-secondary font-normal ml-2">
item{dashboard.shopping_unchecked !== 1 ? 's' : ''} to buy
</span>
</p>
<p className="text-xs text-muted mt-3">
Tap to open your shopping list.
</p>
</div>
)
) : (
<p className="text-secondary text-sm">Loading</p>
)}
</motion.div>
</motion.div>
{/* ── Pinned Messages ────────────────────────────────────────────── */}
{(dashboard?.pinned_messages?.length ?? 0) > 0 && (
<motion.section
variants={fade} initial="hidden" animate="show"
transition={{ delay: 0.25, duration: 0.4 }}
>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-primary flex items-center gap-2">
📌 Pinned Messages
</h2>
<Link to="/board" className="text-xs text-accent hover:underline">
Message board
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{dashboard!.pinned_messages.map((msg) => (
<div
key={msg.id}
className="rounded-xl p-4 shadow-sm"
style={{ background: msg.color }}
>
{msg.emoji && <p className="text-2xl mb-1.5">{msg.emoji}</p>}
<p className="text-sm text-gray-900 font-medium whitespace-pre-wrap break-words leading-relaxed">
{msg.body}
</p>
{msg.member_name && (
<p className="text-xs text-gray-600 mt-2"> {msg.member_name}</p>
)}
</div>
))}
</div>
</motion.section>
)}
{/* ── Countdowns ─────────────────────────────────────────────────── */}
{countdownsToShow.length > 0 && (
<motion.section
variants={fade} initial="hidden" animate="show"
transition={{ delay: 0.35, duration: 0.4 }}
>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-primary flex items-center gap-2">
Countdowns
</h2>
<Link to="/countdowns" className="text-xs text-accent hover:underline">
All countdowns
</Link>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-3">
{countdownsToShow.map((cd) => {
const daysLeft = differenceInCalendarDays(
new Date(cd.target_date + 'T00:00:00'),
new Date(),
);
return (
<div
key={cd.id}
className={`${cardCls} text-center relative overflow-hidden`}
>
{/* Colour strip */}
<div
className="absolute top-0 left-0 right-0 h-1.5"
style={{ background: cd.color }}
/>
<div className="pt-2">
{cd.emoji && (
<p className="text-2xl mb-2 leading-none">{cd.emoji}</p>
)}
<p
className="text-5xl font-thin tabular-nums leading-none"
style={{ color: cd.color }}
>
{daysLeft}
</p>
<p className="text-xs text-muted mt-1">
{daysLeft === 0 ? 'Today!' : daysLeft === 1 ? 'day' : 'days'}
</p>
<p className="text-xs font-semibold text-primary mt-2 leading-tight px-1">
{cd.title}
</p>
</div>
</div>
);
})}
</div>
</motion.section>
)}
</div> </div>
); );
} }

View File

@@ -1,3 +1,444 @@
export default function MealsPage() { import { useState, useMemo } from 'react';
return <div className="p-6"><h1 className="text-2xl font-bold text-primary">Meal Planner</h1><p className="text-secondary mt-2">Phase 2</p></div>; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'framer-motion';
import {
UtensilsCrossed,
ChevronLeft,
ChevronRight,
Pencil,
Trash2,
ExternalLink,
Plus,
} from 'lucide-react';
import {
format,
startOfWeek,
addWeeks,
subWeeks,
addDays,
parseISO,
isToday,
isSameWeek,
} from 'date-fns';
import { api, type Meal } from '@/lib/api';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Modal } from '@/components/ui/Modal';
import { clsx } from 'clsx';
// ── Helpers ────────────────────────────────────────────────────────────────
function getWeekStart(date: Date): Date {
return startOfWeek(date, { weekStartsOn: 1 });
}
function buildWeekDays(weekStart: Date): Date[] {
return Array.from({ length: 7 }, (_, i) => addDays(weekStart, i));
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function MealsPage() {
const qc = useQueryClient();
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
getWeekStart(new Date())
);
const weekKey = format(currentWeekStart, 'yyyy-MM-dd');
const weekEnd = addDays(currentWeekStart, 6);
const isCurrentWeek = isSameWeek(currentWeekStart, new Date(), { weekStartsOn: 1 });
// Modal state
const [formOpen, setFormOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [selectedDate, setSelectedDate] = useState<string>('');
const [editMeal, setEditMeal] = useState<Meal | null>(null);
// Form fields
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [recipeUrl, setRecipeUrl] = useState('');
// ── Query ────────────────────────────────────────────────────────
const { data: meals = [], isLoading } = useQuery<Meal[]>({
queryKey: ['meals', weekKey],
queryFn: () =>
api
.get('/meals', {
params: {
start: weekKey,
end: format(weekEnd, 'yyyy-MM-dd'),
},
})
.then((r) => r.data),
});
const mealMap = useMemo(() => {
const map = new Map<string, Meal>();
for (const m of meals) {
map.set(m.date, m);
}
return map;
}, [meals]);
// ── Mutations ────────────────────────────────────────────────────
const upsertMutation = useMutation({
mutationFn: ({
date,
body,
}: {
date: string;
body: { title: string; description?: string; recipe_url?: string };
}) => api.put(`/meals/${date}`, body).then((r) => r.data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['meals', weekKey] });
closeForm();
},
});
const deleteMutation = useMutation({
mutationFn: (date: string) => api.delete(`/meals/${date}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['meals', weekKey] });
setDeleteOpen(false);
setSelectedDate('');
},
});
// ── Handlers ─────────────────────────────────────────────────────
function openAdd(date: string) {
setEditMeal(null);
setSelectedDate(date);
setTitle('');
setDescription('');
setRecipeUrl('');
setFormOpen(true);
}
function openEdit(meal: Meal) {
setEditMeal(meal);
setSelectedDate(meal.date);
setTitle(meal.title);
setDescription(meal.description ?? '');
setRecipeUrl(meal.recipe_url ?? '');
setFormOpen(true);
}
function closeForm() {
setFormOpen(false);
setEditMeal(null);
setSelectedDate('');
}
function openDelete(date: string) {
setSelectedDate(date);
setDeleteOpen(true);
}
function handleSave() {
if (!title.trim() || !selectedDate) return;
upsertMutation.mutate({
date: selectedDate,
body: {
title: title.trim(),
description: description.trim() || undefined,
recipe_url: recipeUrl.trim() || undefined,
},
});
}
const weekDays = buildWeekDays(currentWeekStart);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* ── Header ────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-6 py-4 border-b border-theme bg-surface shrink-0 flex-wrap gap-3">
<div className="flex items-center gap-3">
<UtensilsCrossed size={22} className="text-accent" />
<div>
<h1 className="text-xl font-bold text-primary leading-tight">Meal Planner</h1>
<p className="text-xs text-muted">
{format(currentWeekStart, 'MMM d')} {format(weekEnd, 'MMM d, yyyy')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{!isCurrentWeek && (
<Button
variant="secondary"
size="sm"
onClick={() => setCurrentWeekStart(getWeekStart(new Date()))}
>
Today
</Button>
)}
<button
onClick={() => setCurrentWeekStart((w) => getWeekStart(subWeeks(w, 1)))}
className="p-2 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
aria-label="Previous week"
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => setCurrentWeekStart((w) => getWeekStart(addWeeks(w, 1)))}
className="p-2 rounded-lg text-secondary hover:bg-surface-raised hover:text-primary transition-colors"
aria-label="Next week"
>
<ChevronRight size={18} />
</button>
</div>
</div>
{/* ── Week Grid ─────────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto overflow-x-auto p-4">
{isLoading ? (
<div className="grid grid-cols-7 gap-3 min-w-[700px]">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="h-48 rounded-2xl bg-surface-raised animate-pulse" />
))}
</div>
) : (
<div className="grid grid-cols-7 gap-3 min-w-[700px]">
{weekDays.map((day) => {
const dateStr = format(day, 'yyyy-MM-dd');
const meal = mealMap.get(dateStr);
const today = isToday(day);
return (
<div
key={dateStr}
className={clsx(
'flex flex-col rounded-2xl border transition-colors min-h-[180px]',
today
? 'border-accent bg-accent/5'
: 'border-theme bg-surface'
)}
>
{/* Day header */}
<div
className={clsx(
'px-3 pt-3 pb-2 border-b',
today ? 'border-accent/20' : 'border-theme'
)}
>
<p
className={clsx(
'text-xs font-semibold uppercase tracking-wide',
today ? 'text-accent' : 'text-muted'
)}
>
{format(day, 'EEE')}
</p>
<p
className={clsx(
'text-lg font-bold leading-tight',
today ? 'text-accent' : 'text-primary'
)}
>
{format(day, 'd')}
</p>
</div>
{/* Cell content */}
<div className="flex-1 p-3">
{meal ? (
<MealCell
meal={meal}
onEdit={() => openEdit(meal)}
onDelete={() => openDelete(dateStr)}
/>
) : (
<button
onClick={() => openAdd(dateStr)}
className="w-full h-full min-h-[80px] flex flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-theme text-muted hover:border-accent hover:text-accent transition-colors group"
>
<Plus size={16} className="opacity-60 group-hover:opacity-100" />
<span className="text-xs">Add dinner</span>
</button>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── Add / Edit Modal ──────────────────────────────────────── */}
<Modal
open={formOpen}
onClose={closeForm}
title={editMeal ? 'Edit Meal' : 'Add Dinner'}
size="sm"
>
<div className="space-y-4">
{selectedDate && (
<p className="text-xs text-muted -mt-2">
{format(parseISO(selectedDate), 'EEEE, MMMM d')}
</p>
)}
<Input
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
placeholder="e.g. Spaghetti Bolognese"
autoFocus
/>
<Textarea
label="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Notes, ingredients, serving ideas…"
rows={3}
/>
<Input
label="Recipe URL (optional)"
value={recipeUrl}
onChange={(e) => setRecipeUrl(e.target.value)}
placeholder="https://…"
type="url"
/>
<div className="flex items-center gap-2 pt-1">
{editMeal && (
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-600 mr-auto"
onClick={() => {
closeForm();
openDelete(editMeal.date);
}}
>
Remove
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button variant="secondary" onClick={closeForm}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!title.trim()}
loading={upsertMutation.isPending}
>
Save
</Button>
</div>
</div>
</div>
</Modal>
{/* ── Delete Confirm Modal ─────────────────────────────────── */}
<Modal
open={deleteOpen}
onClose={() => {
setDeleteOpen(false);
setSelectedDate('');
}}
title="Remove Meal?"
size="sm"
>
<div className="space-y-4">
<p className="text-secondary text-sm">
This will remove the meal for{' '}
<strong className="text-primary">
{selectedDate ? format(parseISO(selectedDate), 'EEEE, MMMM d') : ''}
</strong>
. This cannot be undone.
</p>
<div className="flex gap-2 justify-end">
<Button
variant="secondary"
onClick={() => {
setDeleteOpen(false);
setSelectedDate('');
}}
>
Cancel
</Button>
<Button
variant="danger"
loading={deleteMutation.isPending}
onClick={() => selectedDate && deleteMutation.mutate(selectedDate)}
>
Remove
</Button>
</div>
</div>
</Modal>
</div>
);
}
// ── MealCell ───────────────────────────────────────────────────────────────
interface MealCellProps {
meal: Meal;
onEdit: () => void;
onDelete: () => void;
}
function MealCell({ meal, onEdit, onDelete }: MealCellProps) {
return (
<AnimatePresence mode="wait">
<motion.div
key={meal.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="group relative h-full flex flex-col gap-1"
>
<p className="text-sm font-semibold text-primary leading-tight line-clamp-2 pr-14">
{meal.title}
</p>
{meal.description && (
<p className="text-xs text-secondary line-clamp-3 leading-relaxed">
{meal.description}
</p>
)}
{/* Hover actions */}
<div className="absolute top-0 right-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{meal.recipe_url && (
<a
href={meal.recipe_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="p-1 rounded text-muted hover:text-accent transition-colors"
aria-label="Open recipe"
>
<ExternalLink size={13} />
</a>
)}
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className="p-1 rounded text-muted hover:text-primary transition-colors"
aria-label="Edit meal"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-muted hover:text-red-500 transition-colors"
aria-label="Delete meal"
>
<Trash2 size={13} />
</button>
</div>
</motion.div>
</AnimatePresence>
);
} }

View File

@@ -12,6 +12,8 @@ import mealsRouter from './routes/meals';
import messagesRouter from './routes/messages'; import messagesRouter from './routes/messages';
import countdownsRouter from './routes/countdowns'; import countdownsRouter from './routes/countdowns';
import photosRouter from './routes/photos'; import photosRouter from './routes/photos';
import dashboardRouter from './routes/dashboard';
import weatherRouter from './routes/weather';
// Run DB migrations on startup — aborts if any migration fails // Run DB migrations on startup — aborts if any migration fails
runMigrations(); runMigrations();
@@ -32,6 +34,8 @@ app.use('/api/meals', mealsRouter);
app.use('/api/messages', messagesRouter); app.use('/api/messages', messagesRouter);
app.use('/api/countdowns', countdownsRouter); app.use('/api/countdowns', countdownsRouter);
app.use('/api/photos', photosRouter); app.use('/api/photos', photosRouter);
app.use('/api/dashboard', dashboardRouter);
app.use('/api/weather', weatherRouter);
// Serve built client — in Docker the client dist is copied here at build time // Serve built client — in Docker the client dist is copied here at build time
const CLIENT_DIST = path.join(__dirname, '../../client/dist'); const CLIENT_DIST = path.join(__dirname, '../../client/dist');

View File

@@ -0,0 +1,69 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (_req, res) => {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
const nowIso = new Date().toISOString().replace('T', ' ').slice(0, 19);
const in7days = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString().replace('T', ' ').slice(0, 19);
// Today's meal
const meal_today = db.prepare('SELECT * FROM meals WHERE date = ?').get(today) ?? null;
// Upcoming events in the next 7 days
const upcoming_events = db.prepare(`
SELECT e.*, m.name AS member_name, m.color AS member_color
FROM events e
LEFT JOIN members m ON e.member_id = m.id
WHERE e.start_at >= ? AND e.start_at <= ?
ORDER BY e.start_at ASC
LIMIT 10
`).all(nowIso, in7days);
// Pending chores (up to 10 for preview)
const pending_chores = db.prepare(`
SELECT c.*, m.name AS member_name, m.color AS member_color,
(SELECT COUNT(*) FROM chore_completions cc WHERE cc.chore_id = c.id) AS completion_count
FROM chores c
LEFT JOIN members m ON c.member_id = m.id
WHERE c.status = 'pending'
ORDER BY c.due_date ASC NULLS LAST
LIMIT 10
`).all();
// Unchecked shopping items count
const shoppingRow = db.prepare(
'SELECT COUNT(*) AS count FROM shopping_items WHERE checked = 0'
).get() as { count: number };
// Pinned messages (not expired)
const pinned_messages = db.prepare(`
SELECT msg.*, m.name AS member_name, m.color AS member_color
FROM messages msg
LEFT JOIN members m ON msg.member_id = m.id
WHERE msg.pinned = 1
AND (msg.expires_at IS NULL OR msg.expires_at > ?)
ORDER BY msg.created_at DESC
LIMIT 6
`).all(nowIso);
// Countdowns marked for dashboard
const countdowns = db.prepare(`
SELECT * FROM countdowns
WHERE show_on_dashboard = 1
ORDER BY target_date ASC
`).all();
res.json({
meal_today,
upcoming_events,
pending_chores,
shopping_unchecked: (shoppingRow as any).count as number,
pinned_messages,
countdowns,
});
});
export default router;

View File

@@ -0,0 +1,54 @@
import { Router } from 'express';
import https from 'https';
import db from '../db/db';
const router = Router();
function getSetting(key: string): string {
return (db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as any)?.value ?? '';
}
router.get('/', (_req, res) => {
const apiKey = getSetting('weather_api_key').trim();
const location = getSetting('weather_location').trim();
const units = getSetting('weather_units').trim() || 'imperial';
if (!apiKey || !location) {
return res.json({ configured: false });
}
const url =
`https://api.openweathermap.org/data/2.5/weather` +
`?q=${encodeURIComponent(location)}` +
`&appid=${encodeURIComponent(apiKey)}` +
`&units=${encodeURIComponent(units)}`;
https.get(url, (apiRes) => {
let raw = '';
apiRes.on('data', (chunk: Buffer) => { raw += chunk.toString(); });
apiRes.on('end', () => {
try {
const data = JSON.parse(raw);
if (apiRes.statusCode !== 200) {
return res.json({ configured: true, error: data.message ?? 'Weather API error' });
}
res.json({
configured: true,
city: data.name as string,
temp: Math.round(data.main.temp as number),
feels_like: Math.round(data.main.feels_like as number),
humidity: data.main.humidity as number,
description: (data.weather?.[0]?.description ?? '') as string,
icon: (data.weather?.[0]?.icon ?? '') as string,
units,
});
} catch {
res.json({ configured: true, error: 'Failed to parse weather response' });
}
});
}).on('error', (err: Error) => {
res.json({ configured: true, error: err.message });
});
});
export default router;