more roadmap features
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
All checks were successful
Build and Push Docker Image / build (push) Successful in 16s
This commit is contained in:
@@ -1,3 +1,474 @@
|
||||
export default function BoardPage() {
|
||||
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 { useState } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,407 @@
|
||||
export default function CountdownsPage() {
|
||||
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 { useState } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-primary mb-2">Good morning 👋</h1>
|
||||
<p className="text-secondary">Your family dashboard is coming together. More widgets arriving in Phase 2.</p>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-primary flex items-center gap-2">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,444 @@
|
||||
export default function MealsPage() {
|
||||
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 { useState, useMemo } from 'react';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import mealsRouter from './routes/meals';
|
||||
import messagesRouter from './routes/messages';
|
||||
import countdownsRouter from './routes/countdowns';
|
||||
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
|
||||
runMigrations();
|
||||
@@ -32,6 +34,8 @@ app.use('/api/meals', mealsRouter);
|
||||
app.use('/api/messages', messagesRouter);
|
||||
app.use('/api/countdowns', countdownsRouter);
|
||||
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
|
||||
const CLIENT_DIST = path.join(__dirname, '../../client/dist');
|
||||
|
||||
69
apps/server/src/routes/dashboard.ts
Normal file
69
apps/server/src/routes/dashboard.ts
Normal 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;
|
||||
54
apps/server/src/routes/weather.ts
Normal file
54
apps/server/src/routes/weather.ts
Normal 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;
|
||||
Reference in New Issue
Block a user