added reminder

This commit is contained in:
2026-06-11 11:04:22 +03:00
parent 363aca293a
commit f7cc238308
22 changed files with 1265 additions and 2 deletions
+3
View File
@@ -5,6 +5,7 @@ import { useVisualViewportHeight } from "./hooks/useVisualViewport";
import Character from "./pages/Character";
import Chat from "./pages/Chat";
import Fitness from "./pages/Fitness";
import Reminders from "./pages/Reminders";
import Shopping from "./pages/Shopping";
import Memory from "./pages/Memory";
import Pomodoro from "./pages/Pomodoro";
@@ -27,6 +28,7 @@ export default function App() {
<NavLink to="/memory">Память</NavLink>
<NavLink to="/fitness">Фитнес</NavLink>
<NavLink to="/shopping">Покупки</NavLink>
<NavLink to="/reminders">Календарь</NavLink>
<PomodoroWidget compact />
</nav>
</header>
@@ -38,6 +40,7 @@ export default function App() {
<Route path="/memory" element={<Memory />} />
<Route path="/fitness" element={<Fitness />} />
<Route path="/shopping" element={<Shopping />} />
<Route path="/reminders" element={<Reminders />} />
</Routes>
</main>
</div>
+63
View File
@@ -422,6 +422,33 @@ export const api = {
request<{ ok: boolean }>(`/api/v1/shopping/lists/${listId}/clear-checked`, {
method: "POST",
}),
getRemindersSnapshot: () => request<RemindersSnapshot>("/api/v1/reminders"),
getRemindersCalendar: (year: number, month: number) =>
request<RemindersCalendar>(`/api/v1/reminders/calendar?year=${year}&month=${month}`),
createReminder: (payload: ReminderCreatePayload) =>
request<{ ok: boolean; reminder: Reminder }>("/api/v1/reminders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
updateReminder: (id: number, payload: Partial<ReminderCreatePayload> & { enabled?: boolean }) =>
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}),
deleteReminder: (id: number) =>
request<{ ok: boolean }>(`/api/v1/reminders/${id}`, { method: "DELETE" }),
completeReminder: (id: number) =>
request<{ ok: boolean; reminder: Reminder }>(`/api/v1/reminders/${id}/complete`, {
method: "POST",
}),
};
export interface ShoppingListItem {
@@ -449,3 +476,39 @@ export interface ShoppingSnapshot {
total_items: number;
unchecked_items: number;
}
export interface Reminder {
id: number;
title: string;
notes: string;
due_at: string;
due_at_local: string;
all_day: boolean;
recurrence: string;
enabled: boolean;
completed_at: string | null;
timezone: string;
created_at: string | null;
}
export interface RemindersSnapshot {
notify_seq: number;
upcoming: Reminder[];
upcoming_count: number;
timezone: string;
}
export interface RemindersCalendar {
year: number;
month: number;
timezone: string;
reminders: Reminder[];
}
export interface ReminderCreatePayload {
title: string;
due_at: string;
notes?: string;
all_day?: boolean;
recurrence?: string;
}
+38
View File
@@ -22,6 +22,7 @@ function noticeLabel(content: string): string {
if (content.startsWith("🎨")) return "картинка";
if (content.startsWith("⚠️")) return "сервер";
if (content.startsWith("🛒")) return "покупки";
if (content.startsWith("📅")) return "напоминание";
return "система";
}
@@ -54,6 +55,8 @@ export default function Chat() {
const scrollRafRef = useRef<number | null>(null);
const { status: pomodoroStatus, refresh: refreshPomodoro } = usePomodoro();
const [lastNotifySeq, setLastNotifySeq] = useState(0);
const lastReminderNotifySeq = useRef(0);
const remindersNotifyReady = useRef(false);
const pendingHistoryReload = useRef(false);
const loadSessions = async () => {
@@ -134,6 +137,41 @@ export default function Chat() {
}
}, [pomodoroStatus?.cycle?.chat_notify_seq, activeId, lastNotifySeq, refreshPomodoro, loading]);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const data = await api.getRemindersSnapshot();
if (cancelled) return;
if (!remindersNotifyReady.current) {
remindersNotifyReady.current = true;
lastReminderNotifySeq.current = data.notify_seq;
return;
}
if (data.notify_seq > lastReminderNotifySeq.current) {
lastReminderNotifySeq.current = data.notify_seq;
if (activeId) {
if (loading) {
pendingHistoryReload.current = true;
} else {
loadMessages(activeId).catch(console.error);
}
}
}
} catch {
// ignore polling errors
}
};
poll().catch(console.error);
const id = setInterval(() => poll().catch(console.error), 60000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [activeId, loading]);
const handleNewChat = async () => {
const session = await api.createSession();
await loadSessions();
+193
View File
@@ -0,0 +1,193 @@
.reminders-page {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
height: 100%;
min-height: 0;
overflow: auto;
padding: 1rem;
}
.reminders-calendar-card,
.reminders-sidebar {
background: #12151c;
border: 1px solid #2f3748;
border-radius: 12px;
padding: 1rem;
overflow: auto;
}
.reminders-calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.reminders-calendar-header h2 {
margin: 0;
font-size: 1.2rem;
}
.reminders-calendar-header button {
width: 2rem;
height: 2rem;
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.reminders-tz {
margin: 0 0 0.75rem;
color: #8b95a5;
font-size: 0.85rem;
}
.reminders-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.25rem;
margin-bottom: 0.35rem;
color: #8b95a5;
font-size: 0.8rem;
text-align: center;
}
.reminders-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.35rem;
}
.reminders-day {
position: relative;
min-height: 3.2rem;
border: 1px solid #2f3748;
border-radius: 8px;
background: #1a1f2b;
color: inherit;
cursor: pointer;
padding: 0.35rem;
}
.reminders-day-empty {
border: none;
background: transparent;
cursor: default;
}
.reminders-day.today {
border-color: #4a7cff;
}
.reminders-day.selected {
background: #1c2740;
border-color: #6b93ff;
}
.reminders-day-num {
font-size: 0.9rem;
}
.reminders-day-badge {
position: absolute;
right: 0.35rem;
bottom: 0.35rem;
min-width: 1.1rem;
height: 1.1rem;
border-radius: 999px;
background: #4a7cff;
color: #fff;
font-size: 0.7rem;
line-height: 1.1rem;
text-align: center;
}
.reminders-day-panel {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #2f3748;
}
.reminders-day-panel ul,
.reminders-upcoming {
list-style: none;
margin: 0;
padding: 0;
}
.reminders-day-panel li,
.reminders-upcoming li {
padding: 0.65rem 0;
border-bottom: 1px solid #2a3140;
}
.reminders-upcoming-title {
font-weight: 600;
}
.reminders-upcoming-meta {
color: #8b95a5;
font-size: 0.85rem;
margin-top: 0.15rem;
}
.reminders-item-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.4rem;
}
.reminders-item-actions button {
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
border-radius: 6px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
cursor: pointer;
}
.reminders-form {
display: flex;
flex-direction: column;
gap: 0.65rem;
margin-bottom: 1.25rem;
}
.reminders-form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: #b8c0cc;
}
.reminders-form input,
.reminders-form select,
.reminders-form textarea {
border-radius: 8px;
border: 1px solid #2f3748;
background: #1a1f2b;
color: inherit;
padding: 0.5rem 0.65rem;
}
.reminders-muted {
color: #8b95a5;
font-size: 0.9rem;
}
.reminders-error {
color: #ff8f8f;
margin-top: 0.75rem;
}
@media (max-width: 900px) {
.reminders-page {
grid-template-columns: 1fr;
}
}
+300
View File
@@ -0,0 +1,300 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { api, Reminder } from "../api/client";
import "./Reminders.css";
const WEEKDAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
const MONTHS = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function dateKey(year: number, month: number, day: number) {
return `${year}-${pad2(month)}-${pad2(day)}`;
}
function defaultDatetimeLocal() {
const d = new Date();
d.setMinutes(d.getMinutes() + 30);
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
}
function toIsoDueAt(localValue: string) {
const d = new Date(localValue);
const offset = -d.getTimezoneOffset();
const sign = offset >= 0 ? "+" : "-";
const abs = Math.abs(offset);
const hh = pad2(Math.floor(abs / 60));
const mm = pad2(abs % 60);
return `${localValue}:00${sign}${hh}:${mm}`;
}
export default function Reminders() {
const now = new Date();
const [year, setYear] = useState(now.getFullYear());
const [month, setMonth] = useState(now.getMonth() + 1);
const [reminders, setReminders] = useState<Reminder[]>([]);
const [upcoming, setUpcoming] = useState<Reminder[]>([]);
const [timezone, setTimezone] = useState("");
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [title, setTitle] = useState("");
const [dueLocal, setDueLocal] = useState(defaultDatetimeLocal);
const [notes, setNotes] = useState("");
const [recurrence, setRecurrence] = useState("none");
const load = useCallback(async () => {
setLoading(true);
try {
const [calendar, snapshot] = await Promise.all([
api.getRemindersCalendar(year, month),
api.getRemindersSnapshot(),
]);
setReminders(calendar.reminders);
setTimezone(calendar.timezone || snapshot.timezone);
setUpcoming(snapshot.upcoming);
setMessage("");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setLoading(false);
}
}, [year, month]);
useEffect(() => {
load().catch(console.error);
}, [load]);
const byDay = useMemo(() => {
const map = new Map<string, Reminder[]>();
for (const item of reminders) {
const key = item.due_at_local.slice(0, 10);
const list = map.get(key) ?? [];
list.push(item);
map.set(key, list);
}
return map;
}, [reminders]);
const monthCells = useMemo(() => {
const first = new Date(year, month - 1, 1);
const daysInMonth = new Date(year, month, 0).getDate();
const startPad = (first.getDay() + 6) % 7;
const cells: Array<{ day: number | null; key: string | null }> = [];
for (let i = 0; i < startPad; i++) cells.push({ day: null, key: null });
for (let day = 1; day <= daysInMonth; day++) {
cells.push({ day, key: dateKey(year, month, day) });
}
return cells;
}, [year, month]);
const selectedReminders = selectedDay ? byDay.get(selectedDay) ?? [] : [];
const shiftMonth = (delta: number) => {
let m = month + delta;
let y = year;
if (m < 1) {
m = 12;
y -= 1;
} else if (m > 12) {
m = 1;
y += 1;
}
setMonth(m);
setYear(y);
setSelectedDay(null);
};
const handleCreate = async (e: FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
try {
await api.createReminder({
title: title.trim(),
due_at: toIsoDueAt(dueLocal),
notes: notes.trim(),
recurrence,
});
setTitle("");
setNotes("");
setRecurrence("none");
setDueLocal(defaultDatetimeLocal());
await load();
} catch (err) {
setMessage(err instanceof Error ? err.message : "Ошибка");
}
};
const handleComplete = async (id: number) => {
await api.completeReminder(id);
await load();
};
const handleDelete = async (id: number) => {
await api.deleteReminder(id);
await load();
};
const todayKey = dateKey(now.getFullYear(), now.getMonth() + 1, now.getDate());
return (
<div className="reminders-page">
<section className="reminders-calendar-card">
<header className="reminders-calendar-header">
<button type="button" onClick={() => shiftMonth(-1)} aria-label="Предыдущий месяц">
</button>
<h2>
{MONTHS[month - 1]} {year}
</h2>
<button type="button" onClick={() => shiftMonth(1)} aria-label="Следующий месяц">
</button>
</header>
{timezone && <p className="reminders-tz">Часовой пояс: {timezone}</p>}
<div className="reminders-weekdays">
{WEEKDAYS.map((d) => (
<span key={d}>{d}</span>
))}
</div>
<div className="reminders-grid">
{monthCells.map((cell, idx) => {
if (!cell.day || !cell.key) {
return <div key={`empty-${idx}`} className="reminders-day reminders-day-empty" />;
}
const count = byDay.get(cell.key)?.length ?? 0;
const isToday = cell.key === todayKey;
const isSelected = cell.key === selectedDay;
return (
<button
key={cell.key}
type="button"
className={`reminders-day${isToday ? " today" : ""}${isSelected ? " selected" : ""}`}
onClick={() => setSelectedDay(cell.key)}
>
<span className="reminders-day-num">{cell.day}</span>
{count > 0 && <span className="reminders-day-badge">{count}</span>}
</button>
);
})}
</div>
{selectedDay && (
<div className="reminders-day-panel">
<h3>{selectedDay}</h3>
{selectedReminders.length === 0 ? (
<p className="reminders-muted">Нет напоминаний</p>
) : (
<ul>
{selectedReminders.map((r) => (
<li key={r.id}>
<strong>{r.title}</strong>
<span>{r.all_day ? "весь день" : r.due_at_local.slice(11)}</span>
{r.notes && <p>{r.notes}</p>}
<div className="reminders-item-actions">
<button type="button" onClick={() => handleComplete(r.id)}>
Готово
</button>
<button type="button" onClick={() => handleDelete(r.id)}>
Удалить
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
</section>
<aside className="reminders-sidebar">
<h3>Новое напоминание</h3>
<form className="reminders-form" onSubmit={handleCreate}>
<label>
Текст
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Поесть, одеть куртку…"
required
/>
</label>
<label>
Когда
<input
type="datetime-local"
value={dueLocal}
onChange={(e) => setDueLocal(e.target.value)}
required
/>
</label>
<label>
Повтор
<select value={recurrence} onChange={(e) => setRecurrence(e.target.value)}>
<option value="none">Один раз</option>
<option value="daily">Каждый день</option>
<option value="weekly">Каждую неделю</option>
<option value="monthly">Каждый месяц</option>
</select>
</label>
<label>
Заметка
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Необязательно"
/>
</label>
<button type="submit" className="primary-btn">
Добавить
</button>
</form>
<h3>Ближайшие</h3>
{loading && <p className="reminders-muted">Загрузка</p>}
<ul className="reminders-upcoming">
{upcoming.length === 0 && !loading && (
<li className="reminders-muted">Пока пусто попроси в чате: «напомни через 15 минут»</li>
)}
{upcoming.map((r) => (
<li key={r.id}>
<div className="reminders-upcoming-title">{r.title}</div>
<div className="reminders-upcoming-meta">
{r.due_at_local}
{r.recurrence !== "none" ? ` · ${r.recurrence}` : ""}
</div>
<div className="reminders-item-actions">
<button type="button" onClick={() => handleComplete(r.id)}>
Готово
</button>
<button type="button" onClick={() => handleDelete(r.id)}>
Удалить
</button>
</div>
</li>
))}
</ul>
{message && <p className="reminders-error">{message}</p>}
</aside>
</div>
);
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/context/pomodorocontext.tsx","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/pomodoro.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/time.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/pomodorowidget.tsx","./src/context/pomodorocontext.tsx","./src/hooks/usevisualviewport.ts","./src/pages/character.tsx","./src/pages/chat.tsx","./src/pages/fitness.tsx","./src/pages/memory.tsx","./src/pages/pomodoro.tsx","./src/pages/reminders.tsx","./src/pages/shopping.tsx","./src/utils/charactercard.ts","./src/utils/pomodoro.ts","./src/utils/pomodorocountdown.ts","./src/utils/time.ts"],"version":"5.9.3"}