From a0c1ae970310f2c9e2b3a481c4676a4e05801926 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 30 Mar 2026 14:34:10 -0500 Subject: [PATCH] more roadmap features --- apps/client/src/pages/Board.tsx | 475 ++++++++++++++++++++++++++- apps/client/src/pages/Countdowns.tsx | 408 ++++++++++++++++++++++- apps/client/src/pages/Dashboard.tsx | 410 ++++++++++++++++++++++- apps/client/src/pages/Meals.tsx | 445 ++++++++++++++++++++++++- apps/server/src/index.ts | 4 + apps/server/src/routes/dashboard.ts | 69 ++++ apps/server/src/routes/weather.ts | 54 +++ 7 files changed, 1855 insertions(+), 10 deletions(-) create mode 100644 apps/server/src/routes/dashboard.ts create mode 100644 apps/server/src/routes/weather.ts diff --git a/apps/client/src/pages/Board.tsx b/apps/client/src/pages/Board.tsx index ce0c8e3..c86d010 100644 --- a/apps/client/src/pages/Board.tsx +++ b/apps/client/src/pages/Board.tsx @@ -1,3 +1,474 @@ -export default function BoardPage() { - return

Message Board

Phase 4

; +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(null); + const [deleteTarget, setDeleteTarget] = useState(null); + + // Form fields + const [body, setBody] = useState(''); + const [color, setColor] = useState(NOTE_COLORS[0]); + const [emoji, setEmoji] = useState(''); + const [memberId, setMemberId] = useState(null); + const [pinned, setPinned] = useState(false); + const [expiryOption, setExpiryOption] = useState(''); + + // ── Query ──────────────────────────────────────────────────────── + const { data: messages = [], isLoading } = useQuery({ + 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 ( +
+ {/* ── Header ────────────────────────────────────────────────── */} +
+
+ +
+

Message Board

+

+ {messages.length} note{messages.length !== 1 ? 's' : ''} +

+
+
+ +
+ + {/* ── Content ───────────────────────────────────────────────── */} +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : messages.length === 0 ? ( + +
📌
+

No notes yet

+

+ Leave a message for the family to see. +

+ +
+ ) : ( + <> + {/* Pinned section */} + {pinnedMessages.length > 0 && ( +
+
+ + + Pinned + +
+
+ + {pinnedMessages.map((msg) => ( + openEdit(msg)} + onDelete={() => setDeleteTarget(msg)} + onTogglePin={() => + patchMutation.mutate({ id: msg.id, pinned: !msg.pinned }) + } + /> + ))} + +
+
+ )} + + {/* Regular section */} + {regularMessages.length > 0 && ( +
+
+ + {regularMessages.map((msg) => ( + openEdit(msg)} + onDelete={() => setDeleteTarget(msg)} + onTogglePin={() => + patchMutation.mutate({ id: msg.id, pinned: !msg.pinned }) + } + /> + ))} + +
+
+ )} + + )} +
+ + {/* ── Compose / Edit Modal ─────────────────────────────────── */} + +
+ {/* Colour swatches */} +
+

Colour

+
+ {NOTE_COLORS.map((c) => ( +
+
+ + {/* Emoji */} +
+ + 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" + /> +
+ + {/* Body */} +