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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -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")
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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") },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user