feat: Morning brief system with calendar & 6am cron
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import MorningBriefCalendar from "@/components/morning-brief/MorningBriefCalendar";
|
||||
import { MorningBriefProvider } from "@/lib/morning-brief/store";
|
||||
|
||||
export default function MorningBriefPage() {
|
||||
return (
|
||||
<MorningBriefProvider>
|
||||
<div className="min-h-screen bg-[#1a1625] text-white p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-3xl font-bold">☀️ Morning Brief</h1>
|
||||
<p className="text-white/60 mt-2">
|
||||
Daily intelligence at 6am CET. Weather, AI news, tasks, and market.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<MorningBriefCalendar />
|
||||
</div>
|
||||
</div>
|
||||
</MorningBriefProvider>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,21 @@ export default function MissionControlDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Navigation */}
|
||||
<nav className="flex items-center gap-2 mr-4">
|
||||
<a
|
||||
href="/mission-control"
|
||||
className="px-3 py-1.5 rounded-lg bg-white/10 text-sm font-medium hover:bg-white/20 transition"
|
||||
>
|
||||
Tasks
|
||||
</a>
|
||||
<a
|
||||
href="/morning-brief"
|
||||
className="px-3 py-1.5 rounded-lg text-sm font-medium text-white/70 hover:bg-white/10 transition"
|
||||
>
|
||||
☀️ Brief
|
||||
</a>
|
||||
</nav>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-white/60">Total Progress</p>
|
||||
<p className="text-2xl font-bold">{progress}%</p>
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useMorningBrief } from "@/lib/morning-brief/store";
|
||||
import { MorningBrief } from "@/lib/morning-brief/types";
|
||||
|
||||
const fadeUp = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 },
|
||||
};
|
||||
|
||||
export default function MorningBriefCalendar() {
|
||||
const { briefs, todayBrief, generateBrief, getBriefByDate } = useMorningBrief();
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
const [viewingBrief, setViewingBrief] = useState<MorningBrief | null>(null);
|
||||
|
||||
// Get current month for calendar
|
||||
const today = new Date();
|
||||
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
|
||||
const [currentYear, setCurrentYear] = useState(today.getFullYear());
|
||||
|
||||
const monthNames = [
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December"
|
||||
];
|
||||
|
||||
// Generate calendar days
|
||||
const getDaysInMonth = (month: number, year: number) => {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
};
|
||||
|
||||
const getFirstDayOfMonth = (month: number, year: number) => {
|
||||
return new Date(year, month, 1).getDay();
|
||||
};
|
||||
|
||||
const daysInMonth = getDaysInMonth(currentMonth, currentYear);
|
||||
const firstDay = getFirstDayOfMonth(currentMonth, currentYear);
|
||||
|
||||
// Generate brief dates map
|
||||
const briefDates = new Set(briefs.map((b) => b.date));
|
||||
const todayStr = today.toISOString().split("T")[0];
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
if (currentMonth === 0) {
|
||||
setCurrentMonth(11);
|
||||
setCurrentYear(currentYear - 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
if (currentMonth === 11) {
|
||||
setCurrentMonth(0);
|
||||
setCurrentYear(currentYear + 1);
|
||||
} else {
|
||||
setCurrentMonth(currentMonth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateClick = (day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const brief = getBriefByDate(dateStr);
|
||||
if (brief) {
|
||||
setSelectedDate(dateStr);
|
||||
setViewingBrief(brief);
|
||||
}
|
||||
};
|
||||
|
||||
// Calendar grid
|
||||
const calendarDays = [];
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
calendarDays.push(<div key={`empty-${i}`} className="h-10" />);
|
||||
}
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
const hasBrief = briefDates.has(dateStr);
|
||||
const isToday = dateStr === todayStr;
|
||||
|
||||
calendarDays.push(
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => handleDateClick(day)}
|
||||
disabled={!hasBrief}
|
||||
className={`h-10 rounded-lg text-sm transition ${
|
||||
hasBrief
|
||||
? "bg-brand-pink/20 text-white hover:bg-brand-pink/40"
|
||||
: "text-white/30"
|
||||
} ${isToday ? "ring-2 ring-brand-pink" : ""}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Calendar */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={handlePrevMonth}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<h3 className="text-lg font-semibold">
|
||||
{monthNames[currentMonth]} {currentYear}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleNextMonth}
|
||||
className="p-2 rounded-lg hover:bg-white/10 transition"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"].map((d) => (
|
||||
<div key={d} className="h-8 text-center text-xs text-white/50 font-medium">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{calendarDays}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Brief Preview */}
|
||||
{todayBrief && (
|
||||
<div className="rounded-xl border border-brand-pink/30 bg-brand-pink/10 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Today's Brief</h3>
|
||||
<span className="text-xs text-white/50">{todayBrief.generatedAt}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{/* Weather */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-white/50 uppercase">Weather</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🌤️</span>
|
||||
<div>
|
||||
<p className="font-semibold">{todayBrief.weather?.temperature}</p>
|
||||
<p className="text-sm text-white/70">{todayBrief.weather?.condition}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Market */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-white/50 uppercase">Market</p>
|
||||
<div>
|
||||
<p className="font-semibold">{todayBrief.market.usIndex}</p>
|
||||
<p className="text-sm text-white/70">{todayBrief.market.crypto}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<p className="text-xs text-white/50 uppercase">My Tasks Today</p>
|
||||
<ul className="space-y-1">
|
||||
{todayBrief.myTasks.pending.slice(0, 3).map((task, i) => (
|
||||
<li key={i} className="text-sm text-white/80">• {task}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brief Detail Modal */}
|
||||
{viewingBrief && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={() => setViewingBrief(null)}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative z-10 w-full max-w-2xl max-h-[80vh] overflow-y-auto rounded-2xl border border-white/20 bg-[#2d2640] p-6 text-white"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<p className="text-xs text-white/50 uppercase">Brief Date</p>
|
||||
<h3 className="text-xl font-bold">{viewingBrief.date}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setViewingBrief(null)}
|
||||
className="p-2 rounded-lg hover:bg-white/10"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Weather */}
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-white/70 mb-2">🌤️ Weather</h4>
|
||||
<p>{viewingBrief.weather?.temperature} — {viewingBrief.weather?.condition}</p>
|
||||
<p className="text-sm text-white/60">{viewingBrief.weather?.location}</p>
|
||||
</section>
|
||||
|
||||
{/* AI News */}
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-white/70 mb-2">🤖 AI News</h4>
|
||||
<ul className="space-y-1">
|
||||
{viewingBrief.aiNews.items.map((item, i) => (
|
||||
<li key={i} className="text-sm">• {item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Tasks */}
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-white/70 mb-2">✓ My Tasks</h4>
|
||||
{viewingBrief.myTasks.pending.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-white/50">Pending</p>
|
||||
{viewingBrief.myTasks.pending.map((t, i) => (
|
||||
<p key={i} className="text-sm">• {t}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{viewingBrief.myTasks.inProgress.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-white/50">In Progress</p>
|
||||
{viewingBrief.myTasks.inProgress.map((t, i) => (
|
||||
<p key={i} className="text-sm">• {t}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Market */}
|
||||
<section>
|
||||
<h4 className="text-sm font-semibold text-white/70 mb-2">📈 Market</h4>
|
||||
<p className="mb-1">{viewingBrief.market.usIndex}</p>
|
||||
<p className="mb-2">{viewingBrief.market.crypto}</p>
|
||||
<p className="text-sm">
|
||||
Sentiment: <span className={`font-semibold ${
|
||||
viewingBrief.market.sentiment === "bullish" ? "text-green-400" :
|
||||
viewingBrief.market.sentiment === "bearish" ? "text-red-400" : "text-white/70"
|
||||
}`}>{viewingBrief.market.sentiment.toUpperCase()}</span>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Button */}
|
||||
<button
|
||||
onClick={generateBrief}
|
||||
className="w-full rounded-xl bg-brand-pink py-3 font-semibold text-white hover:bg-[#ff7bc0] transition"
|
||||
>
|
||||
Generate Today's Brief
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
|
||||
import { MorningBrief, defaultBrief } from "./types";
|
||||
|
||||
interface MorningBriefStore {
|
||||
briefs: MorningBrief[];
|
||||
todayBrief: MorningBrief | null;
|
||||
generateBrief: () => Promise<void>;
|
||||
getBriefByDate: (date: string) => MorningBrief | undefined;
|
||||
}
|
||||
|
||||
const MorningBriefContext = createContext<MorningBriefStore | null>(null);
|
||||
|
||||
const STORAGE_KEY = "sitemente:morning-briefs";
|
||||
|
||||
export function MorningBriefProvider({ children }: { children: ReactNode }) {
|
||||
const [briefs, setBriefs] = useState<MorningBrief[]>([]);
|
||||
const [todayBrief, setTodayBrief] = useState<MorningBrief | null>(null);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed)) {
|
||||
setBriefs(parsed);
|
||||
// Find today's brief
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const todayBrief = parsed.find((b: MorningBrief) => b.date === today);
|
||||
if (todayBrief) setTodayBrief(todayBrief);
|
||||
}
|
||||
} catch {
|
||||
// Use default
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save to localStorage on change
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (briefs.length > 0) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(briefs));
|
||||
}
|
||||
}, [briefs]);
|
||||
|
||||
const generateBrief = async () => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Get Mission Control tasks from localStorage
|
||||
let myTasks = { pending: [], inProgress: [], dueToday: [] };
|
||||
try {
|
||||
const mcTasks = localStorage.getItem("sitemente:mission-control");
|
||||
if (mcTasks) {
|
||||
const tasks = JSON.parse(mcTasks);
|
||||
myTasks = {
|
||||
pending: tasks.filter((t: any) => t.status === "todo").map((t: any) => t.title).slice(0, 5),
|
||||
inProgress: tasks.filter((t: any) => t.status === "in_progress").map((t: any) => t.title).slice(0, 3),
|
||||
dueToday: tasks.filter((t: any) => t.dueDate === today).map((t: any) => t.title),
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load Mission Control tasks", e);
|
||||
}
|
||||
|
||||
// Mock data - in production, these would be API calls
|
||||
const newBrief: MorningBrief = {
|
||||
id: `brief-${today}`,
|
||||
date: today,
|
||||
generatedAt: now,
|
||||
weather: {
|
||||
location: "Benalmádena, Málaga",
|
||||
temperature: "18°C",
|
||||
condition: "Partly Cloudy",
|
||||
humidity: "65%",
|
||||
wind: "12 km/h NW",
|
||||
},
|
||||
aiNews: {
|
||||
items: [
|
||||
"OpenAI announces GPT-5 roadmap with enhanced reasoning",
|
||||
"Google DeepMind achieves breakthrough in protein folding",
|
||||
"European AI Act enters enforcement phase",
|
||||
"Nvidia reports record AI chip demand",
|
||||
],
|
||||
},
|
||||
things3Tasks: {
|
||||
today: [
|
||||
"Review SiteMente pricing page",
|
||||
"Call with potential restaurant client",
|
||||
"Update HolaCompi roadmap",
|
||||
],
|
||||
overdue: [],
|
||||
},
|
||||
myTasks,
|
||||
market: {
|
||||
usIndex: "S&P 500: +0.3%",
|
||||
crypto: "BTC: $67,500 (+1.2%)",
|
||||
sentiment: "bullish",
|
||||
topNews: [
|
||||
"Fed signals potential rate cut in March",
|
||||
"Bitcoin ETF inflows hit record high",
|
||||
"Tech earnings exceed expectations",
|
||||
],
|
||||
events: [
|
||||
"US Jobs Report (8:30 AM ET)",
|
||||
"Crypto Treasury Meeting (2:00 PM ET)",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
setBriefs((prev) => {
|
||||
const filtered = prev.filter((b) => b.date !== today);
|
||||
return [...filtered, newBrief];
|
||||
});
|
||||
setTodayBrief(newBrief);
|
||||
};
|
||||
|
||||
const getBriefByDate = (date: string) => {
|
||||
return briefs.find((b) => b.date === date);
|
||||
};
|
||||
|
||||
return (
|
||||
<MorningBriefContext.Provider value={{ briefs, todayBrief, generateBrief, getBriefByDate }}>
|
||||
{children}
|
||||
</MorningBriefContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useMorningBrief() {
|
||||
const ctx = useContext(MorningBriefContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useMorningBrief must be used within MorningBriefProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Morning Brief Types
|
||||
|
||||
export interface MorningBrief {
|
||||
id: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
generatedAt: string; // ISO timestamp
|
||||
|
||||
// Weather
|
||||
weather?: {
|
||||
location: string;
|
||||
temperature: string;
|
||||
condition: string;
|
||||
humidity: string;
|
||||
wind: string;
|
||||
};
|
||||
|
||||
// AI News
|
||||
aiNews: {
|
||||
items: string[];
|
||||
};
|
||||
|
||||
// Things 3 Tasks (placeholder - needs integration)
|
||||
things3Tasks?: {
|
||||
today: string[];
|
||||
overdue: string[];
|
||||
};
|
||||
|
||||
// My actionable tasks (from Mission Control)
|
||||
myTasks: {
|
||||
pending: string[];
|
||||
inProgress: string[];
|
||||
dueToday: string[];
|
||||
};
|
||||
|
||||
// Market sentiment
|
||||
market: {
|
||||
usIndex: string;
|
||||
crypto: string;
|
||||
sentiment: 'bullish' | 'bearish' | 'neutral';
|
||||
topNews: string[];
|
||||
events: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const defaultBrief: MorningBrief = {
|
||||
id: '',
|
||||
date: '',
|
||||
generatedAt: '',
|
||||
aiNews: { items: [] },
|
||||
things3Tasks: { today: [], overdue: [] },
|
||||
myTasks: { pending: [], inProgress: [], dueToday: [] },
|
||||
market: {
|
||||
usIndex: '',
|
||||
crypto: '',
|
||||
sentiment: 'neutral',
|
||||
topNews: [],
|
||||
events: [],
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user