From c57982d9108a0596a124d7d62b3fa411f1be08e2 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Jul 2026 08:12:14 -0400 Subject: [PATCH] M3 reminders: notes.remind_at + Reminders view - Migration 0010: notes.remind_at (nullable tz). PATCH accepts remind_at (ISO set / null clear); GET /api/notes/reminders (soonest first, non-trashed); serialize includes remind_at. - Frontend: datetime util (local<->ISO, format, overdue); notes store setReminder; editor datetime-local picker + clear; card reminder chip (overdue = red); sidebar Reminders entry + /reminders view. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- alembic/versions/0010_note_reminder.py | 21 ++++++++++ frontend/src/components/AppShell.vue | 7 ++++ frontend/src/components/Icon.vue | 1 + frontend/src/components/NoteCard.vue | 26 ++++++++++++ frontend/src/components/NoteEditor.vue | 25 +++++++++++ frontend/src/notes/datetime.ts | 28 +++++++++++++ frontend/src/router/index.ts | 1 + frontend/src/stores/notes.ts | 5 ++- frontend/src/views/RemindersView.vue | 57 ++++++++++++++++++++++++++ src/thoughtsync/models/note.py | 3 ++ src/thoughtsync/notes.py | 26 ++++++++++++ tests/test_notes.py | 6 +++ 12 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/0010_note_reminder.py create mode 100644 frontend/src/notes/datetime.ts create mode 100644 frontend/src/views/RemindersView.vue diff --git a/alembic/versions/0010_note_reminder.py b/alembic/versions/0010_note_reminder.py new file mode 100644 index 0000000..40bd69c --- /dev/null +++ b/alembic/versions/0010_note_reminder.py @@ -0,0 +1,21 @@ +"""notes.remind_at + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-07-20 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0010" +down_revision = "0009" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("notes", sa.Column("remind_at", sa.DateTime(timezone=True), nullable=True)) + + +def downgrade() -> None: + op.drop_column("notes", "remind_at") diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 04acf1e..c1e471e 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -130,6 +130,13 @@ async function signOut() { Trash + + Reminders + diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index bcad007..19faf9e 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -17,6 +17,7 @@ const paths: Record = { checkbox: '', image: '', graph: '', + bell: '', }; diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index 5f8abbc..77c06ce 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -5,6 +5,7 @@ import type { Note } from "../stores/notes"; import Icon from "./Icon.vue"; import LinkedText from "./LinkedText.vue"; import NoteChecklist from "./NoteChecklist.vue"; +import { formatReminder, isOverdue } from "../notes/datetime"; defineProps<{ note: Note; reorderable?: boolean }>(); const emit = defineEmits<{ @@ -79,6 +80,31 @@ function cardClass(color: NoteColor): string { > +
+ + + + + + {{ formatReminder(note.remind_at) }} + +
+
diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index 205f6a0..a5e77a1 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -7,6 +7,7 @@ import ColorPicker from "./ColorPicker.vue"; import Icon from "./Icon.vue"; import LabelPicker from "./LabelPicker.vue"; import NoteChecklist from "./NoteChecklist.vue"; +import { fromLocalInput, toLocalInput } from "../notes/datetime"; import type { Note, NoteLabel } from "../stores/notes"; import type { NoteColor } from "../notes/colors"; @@ -86,6 +87,12 @@ async function openLink(link: { title: string; id: string | null }) { emit("navigate", created.id); } +const reminderLocal = computed(() => toLocalInput(liveNote.value.remind_at)); + +function onReminderChange(e: Event) { + void notes.setReminder(props.note.id, fromLocalInput((e.target as HTMLInputElement).value)); +} + async function onLabelsChange(next: NoteLabel[]) { labelList.value = next; await notes.setLabels( @@ -226,6 +233,24 @@ async function act(fn: () => Promise) {
+
+ + + +
+
works in local time. + +export function toLocalInput(iso: string | null): string { + if (!iso) return ""; + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function fromLocalInput(local: string): string | null { + if (!local) return null; + return new Date(local).toISOString(); +} + +export function formatReminder(iso: string | null): string { + if (!iso) return ""; + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function isOverdue(iso: string | null): boolean { + return !!iso && new Date(iso).getTime() < Date.now(); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 2933cb3..fd0108d 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -17,6 +17,7 @@ const router = createRouter({ { path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") }, { path: "search", name: "search", component: () => import("../views/SearchView.vue") }, { path: "graph", name: "graph", component: () => import("../views/GraphView.vue") }, + { path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") }, ], }, { diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 02fa9fd..bbadae0 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -34,6 +34,7 @@ export interface Note { pinned: boolean; archived: boolean; trashed: boolean; + remind_at: string | null; labels: NoteLabel[]; items: ChecklistItem[]; attachments: Attachment[]; @@ -95,7 +96,7 @@ export const useNotesStore = defineStore("notes", () => { async function mutate( id: string, - changes: Partial>, + changes: Partial>, ): Promise { reconcile(await api.patch(`/api/notes/${id}`, changes)); } @@ -104,6 +105,7 @@ export const useNotesStore = defineStore("notes", () => { const setArchived = (id: string, archived: boolean) => mutate(id, { archived }); const setColor = (id: string, color: NoteColor) => mutate(id, { color }); const setKind = (id: string, kind: NoteKind) => mutate(id, { kind }); + const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt }); const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes); async function setLabels(id: string, labelIds: string[]): Promise { @@ -192,6 +194,7 @@ export const useNotesStore = defineStore("notes", () => { setArchived, setColor, setKind, + setReminder, saveEdit, setLabels, addItem, diff --git a/frontend/src/views/RemindersView.vue b/frontend/src/views/RemindersView.vue new file mode 100644 index 0000000..873d720 --- /dev/null +++ b/frontend/src/views/RemindersView.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/thoughtsync/models/note.py b/src/thoughtsync/models/note.py index 39a5a33..3884028 100644 --- a/src/thoughtsync/models/note.py +++ b/src/thoughtsync/models/note.py @@ -48,6 +48,8 @@ class Note(Base): archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) # Soft delete: non-null => in Trash. Restore sets it back to null. deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # Optional reminder time (surfaced in the Reminders view; no push in M3). + remind_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() @@ -64,6 +66,7 @@ class Note(Base): "pinned": self.pinned, "archived": self.archived, "trashed": self.deleted_at is not None, + "remind_at": self.remind_at.isoformat() if self.remind_at else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, } diff --git a/src/thoughtsync/notes.py b/src/thoughtsync/notes.py index dc1b3b6..c4a35b7 100644 --- a/src/thoughtsync/notes.py +++ b/src/thoughtsync/notes.py @@ -198,6 +198,23 @@ async def search_notes(): return jsonify({"notes": await _serialize_notes(db, notes)}) +@bp.get("/reminders") +@login_required +async def list_reminders(): + async with session_scope() as db: + stmt = ( + select(Note) + .where( + visible_to_user("note", Note.owner_id, Note.id, g.user_id), + Note.deleted_at.is_(None), + Note.remind_at.is_not(None), + ) + .order_by(Note.remind_at.asc()) + ) + notes = (await db.scalars(stmt)).all() + return jsonify({"notes": await _serialize_notes(db, notes)}) + + @bp.get("/titles") @login_required async def list_titles(): @@ -343,6 +360,15 @@ async def update_note(note_id: str): note.pinned = bool(data["pinned"]) if "archived" in data: note.archived = bool(data["archived"]) + if "remind_at" in data: + raw = data["remind_at"] + if raw in (None, ""): + note.remind_at = None + else: + try: + note.remind_at = datetime.fromisoformat(str(raw).replace("Z", "+00:00")) + except ValueError: + return jsonify({"error": "invalid remind_at"}), 400 if "body" in data: await _rewrite_links(db, note) await db.commit() diff --git a/tests/test_notes.py b/tests/test_notes.py index 12591ae..4601e7f 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -96,3 +96,9 @@ async def test_graph_requires_auth(app): client = app.test_client() resp = await client.get("/api/graph") assert resp.status_code == 401 + + +async def test_reminders_requires_auth(app): + client = app.test_client() + resp = await client.get("/api/notes/reminders") + assert resp.status_code == 401