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:
@@ -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>
|
||||
Reference in New Issue
Block a user