M3 reminders: notes.remind_at + Reminders view
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 7s
CI & Build / Build & push image (push) Successful in 32s

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-20 08:12:14 -04:00
co-authored by Claude Opus 4.8
parent ad006ccb58
commit c57982d910
12 changed files with 205 additions and 1 deletions
+21
View File
@@ -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")
+7
View File
@@ -130,6 +130,13 @@ async function signOut() {
<RouterLink to="/trash" class="nav-link" :class="route.name === 'trash' ? 'nav-link-active' : ''">
<Icon name="trash" /> Trash
</RouterLink>
<RouterLink
to="/reminders"
class="nav-link"
:class="route.name === 'reminders' ? 'nav-link-active' : ''"
>
<Icon name="bell" /> Reminders
</RouterLink>
</nav>
</aside>
+1
View File
@@ -17,6 +17,7 @@ const paths: Record<string, string> = {
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
};
</script>
+26
View File
@@ -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 {
>
</div>
<div v-if="note.remind_at" class="mt-2">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
:class="
isOverdue(note.remind_at)
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
"
>
<svg
class="h-3 w-3"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{{ formatReminder(note.remind_at) }}
</span>
</div>
<div
class="mt-2 flex items-center justify-end gap-0.5 opacity-0 transition focus-within:opacity-100 group-hover:opacity-100"
>
+25
View File
@@ -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<void>) {
</span>
</div>
<div class="flex items-center gap-2 pt-1">
<Icon name="bell" class="text-neutral-400" />
<input
type="datetime-local"
:value="reminderLocal"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-xs text-neutral-700 outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
@change="onReminderChange"
/>
<button
v-if="liveNote.remind_at"
type="button"
class="text-xs text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200"
@click="notes.setReminder(note.id, null)"
>
Clear
</button>
</div>
<div
v-if="outgoingLinks.length || backlinks.length"
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
+28
View File
@@ -0,0 +1,28 @@
// Reminder datetime helpers. Reminders are stored as ISO UTC; the editor's
// <input type="datetime-local"> 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();
}
+1
View File
@@ -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") },
],
},
{
+4 -1
View File
@@ -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<Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived">>,
changes: Partial<Pick<Note, "title" | "body" | "color" | "kind" | "pinned" | "archived" | "remind_at">>,
): Promise<void> {
reconcile(await api.patch<Note>(`/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<void> {
@@ -192,6 +194,7 @@ export const useNotesStore = defineStore("notes", () => {
setArchived,
setColor,
setKind,
setReminder,
saveEdit,
setLabels,
addItem,
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { api } from "../api/client";
import { useNotesStore, type Note } from "../stores/notes";
import NoteCard from "../components/NoteCard.vue";
import NoteEditor from "../components/NoteEditor.vue";
const notes = useNotesStore();
const items = ref<Note[]>([]);
const loading = ref(true);
const editing = ref<Note | null>(null);
async function load() {
loading.value = true;
try {
const res = await api.get<{ notes: Note[] }>("/api/notes/reminders");
items.value = res.notes;
} finally {
loading.value = false;
}
}
function openEditor(n: Note) {
editing.value = n;
}
async function closeEditor() {
editing.value = null;
await load();
}
async function onNavigate(id: string) {
const found = items.value.find((n) => n.id === id) ?? notes.items.find((n) => n.id === id);
editing.value = found ?? (await notes.fetchOne(id));
}
onMounted(load);
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<h1 class="mb-4 text-lg font-semibold">Reminders</h1>
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading</div>
<div v-else-if="items.length === 0" class="py-24 text-center">
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No reminders</h2>
<p class="mt-1 text-sm text-neutral-400">Set a reminder on a note (in its editor) to see it here.</p>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
<NoteCard v-for="n in items" :key="n.id" :note="n" @open="openEditor" />
</div>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
</template>
</div>
</template>
+3
View File
@@ -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,
}
+26
View File
@@ -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()
+6
View File
@@ -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