refactor(scribe): remove calendar + entity surfaces from web UI (frontend)
CI & Build / Python lint (push) Successful in 4s
CI & Build / integration (push) Successful in 28s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 44s

Frontend half of the narrowing (milestone #194); matches backend b49efdc.

- delete CalendarView, EventSlideOver, WeatherCard (orphan)
- drop /calendar route + nav link + the g→l keyboard shortcut
- strip the calendar-events API client + event/metadata bits from note
  types and the notes store
- KnowledgeView: remove People/Places/Lists tabs, entity cards, create
  buttons and the upcoming-events widget; keep notes/tasks/plans/processes
  + the overdue-task badge
- NoteEditorView: remove person/place/list forms + list-builder + entity
  metadata; keep note + process editors (type select = Note/Process)
- DashboardView: drop the "Upcoming · 7 days" events rail card
- SettingsView: remove the CalDAV integration card + save/test (its
  endpoints were deleted backend-side)
- prune the now-dead entity/event CSS

RecurrenceEditor + task recurrence rules are kept (task machinery, not
calendar). Verified by a full dangler sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPtbSzA4JLMAKgFZ8VTg7Q
This commit is contained in:
2026-07-19 16:10:24 -04:00
parent dd60244429
commit 12f71fabdf
13 changed files with 26 additions and 2589 deletions
-7
View File
@@ -81,7 +81,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
case "p": router.push("/projects"); break;
case "r": router.push("/rules"); break;
case "g": router.push("/graph"); break;
case "l": router.push("/calendar"); break;
case "x": router.push("/trash"); break;
}
return;
@@ -190,12 +189,6 @@ onUnmounted(() => {
<kbd class="shortcut-key">p</kbd>
<span class="shortcut-desc">Projects</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">l</kbd>
<span class="shortcut-desc">Calendar</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
-66
View File
@@ -374,72 +374,6 @@ export async function apiStreamPost(
}
}
// ---------------------------------------------------------------------------
// Calendar events
// ---------------------------------------------------------------------------
export interface EventEntry {
id: number;
uid: string;
title: string;
start_dt: string;
end_dt: string | null;
all_day: boolean;
description: string;
location: string;
color: string;
recurrence: string | null;
caldav_uid: string;
project_id: number | null;
user_id: number;
created_at: string | null;
updated_at: string | null;
}
export interface EventCreatePayload {
title: string;
start_dt: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string;
project_id?: number;
}
export interface EventUpdatePayload {
title?: string;
start_dt?: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string | null;
project_id?: number;
}
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
}
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
return apiPost<EventEntry>('/api/events', payload);
}
export async function getEvent(id: number): Promise<EventEntry> {
return apiGet<EventEntry>(`/api/events/${id}`);
}
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
}
export async function deleteEvent(id: number): Promise<void> {
return apiDelete(`/api/events/${id}`);
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
-2
View File
@@ -47,7 +47,6 @@ router.afterEach(() => {
<div class="nav-pill-bar">
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
</div>
@@ -93,7 +92,6 @@ router.afterEach(() => {
<div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
-677
View File
@@ -1,677 +0,0 @@
<script setup lang="ts">
import { Trash2, X } from "lucide-vue-next";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
import { useToastStore } from "@/stores/toast";
const props = defineProps<{
// null = create mode; EventEntry = edit mode
event: EventEntry | null;
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
initialDate?: string;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "created", event: EventEntry): void;
(e: "updated", event: EventEntry): void;
(e: "deleted", id: number): void;
}>();
const toast = useToastStore();
const isEditMode = computed(() => !!props.event);
const saving = ref(false);
const deleting = ref(false);
const deleteConfirm = ref(false);
// Form fields
const title = ref("");
const startDate = ref("");
const startTime = ref("");
const endDate = ref("");
const endTime = ref("");
const allDay = ref(false);
const description = ref("");
const location = ref("");
const color = ref("");
const projectId = ref<number | null>(null);
const recurrence = ref<string>("");
// Preset RRULE strings. The select binds to `recurrencePreset`, which writes
// through to `recurrence`. CalDAV-imported rules with extra parts
// (e.g. `FREQ=WEEKLY;BYDAY=MO,WE,FR`) fall through to "custom" and the raw
// string is shown read-only below the select.
const RECURRENCE_PRESETS: Record<string, string> = {
none: "",
daily: "FREQ=DAILY",
weekly: "FREQ=WEEKLY",
monthly: "FREQ=MONTHLY",
yearly: "FREQ=YEARLY",
};
const recurrencePreset = computed<string>({
get() {
const r = (recurrence.value || "").trim();
if (!r) return "none";
for (const [key, val] of Object.entries(RECURRENCE_PRESETS)) {
if (val && val === r) return key;
}
return "custom";
},
set(key: string) {
if (key === "custom") return; // no-op; can't pick custom from dropdown
recurrence.value = RECURRENCE_PRESETS[key] ?? "";
},
});
const isCustomRecurrence = computed(() => recurrencePreset.value === "custom");
function dateFromIso(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function timeFromIso(iso: string): string {
if (!iso.includes("T")) return "09:00";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(11, 16);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function toIso(date: string, time: string): string {
if (!time) return `${date}T00:00:00`;
// Include local timezone offset so the server stores the correct UTC time
const local = new Date(`${date}T${time}:00`);
const off = -local.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
const min = String(Math.abs(off) % 60).padStart(2, "0");
return `${date}T${time}:00${sign}${h}:${min}`;
}
// ── Time helpers ──────────────────────────────────────────────────────────────
/** Round up to next 30-minute boundary */
function nextRoundedTime(): string {
const now = new Date();
let h = now.getHours();
let m = now.getMinutes();
if (m <= 30) { m = 30; }
else { m = 0; h = (h + 1) % 24; }
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Add hours to a time string (HH:MM), returns HH:MM */
function addHours(time: string, hours: number): string {
const [h, m] = time.split(":").map(Number);
const totalMin = h * 60 + m + hours * 60;
const nh = Math.floor(totalMin / 60) % 24;
const nm = totalMin % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
/** Duration in minutes between two time strings */
function durationMin(start: string, end: string): number {
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
return (eh * 60 + em) - (sh * 60 + sm);
}
/** True if a date+time is in the past */
const isPastEvent = computed(() => {
if (allDay.value || !startDate.value || !startTime.value) return false;
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
return dt.getTime() < Date.now();
});
// Track duration so end moves with start
let _lastDurationMin = 60;
function resetForm() {
if (props.event) {
title.value = props.event.title;
allDay.value = props.event.all_day;
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
description.value = props.event.description || "";
location.value = props.event.location || "";
color.value = props.event.color || "";
projectId.value = props.event.project_id;
recurrence.value = props.event.recurrence || "";
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
if (_lastDurationMin <= 0) _lastDurationMin = 60;
} else {
title.value = "";
allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
const roundedStart = nextRoundedTime();
startDate.value = base;
startTime.value = roundedStart;
endDate.value = base;
endTime.value = addHours(roundedStart, 1);
description.value = "";
location.value = "";
color.value = "";
projectId.value = null;
recurrence.value = "";
_lastDurationMin = 60;
}
deleteConfirm.value = false;
}
// When start time changes, move end time to preserve duration
watch(startTime, (newStart) => {
if (allDay.value || !newStart) return;
endTime.value = addHours(newStart, _lastDurationMin / 60);
});
// When start date changes, move end date to match (preserve same-day or multi-day gap)
watch(startDate, (newDate) => {
if (!newDate) return;
endDate.value = newDate;
});
// When end time changes manually, update the tracked duration (but guard against end < start)
watch(endTime, (newEnd) => {
if (allDay.value || !newEnd || !startTime.value) return;
const dur = durationMin(startTime.value, newEnd);
if (dur <= 0) {
// Snap back to start + 1 hour
endTime.value = addHours(startTime.value, 1);
_lastDurationMin = 60;
} else {
_lastDurationMin = dur;
}
});
// All-day toggle: clear/restore times
watch(allDay, (isAllDay) => {
if (isAllDay) {
startTime.value = "";
endTime.value = "";
} else {
const rounded = nextRoundedTime();
startTime.value = rounded;
endTime.value = addHours(rounded, 1);
_lastDurationMin = 60;
}
});
watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (deleteConfirm.value) {
// Esc cancels the delete-confirm rather than closing the modal —
// gives the user a clear way out of the destructive prompt.
deleteConfirm.value = false;
return;
}
attemptClose();
}
}
onMounted(() => document.addEventListener("keydown", handleKeydown));
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
// ── Close / save flow ─────────────────────────────────────────────────────────
//
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
//
// Validity-aware behavior:
// - Form valid → save (PATCH for edit, POST for create), then close.
// - Form invalid in EDIT mode → discard the in-memory change and close.
// A toast tells the user what happened so they don't think their edit
// silently landed.
// - Form invalid in CREATE mode → close silently (nothing existed to begin
// with; no need to call this out).
function isFormValid(): { valid: boolean; reason?: string } {
if (!title.value.trim()) {
return { valid: false, reason: "Title required" };
}
if (!startDate.value) {
return { valid: false, reason: "Start date required" };
}
if (!allDay.value && !startTime.value) {
return { valid: false, reason: "Start time required" };
}
return { valid: true };
}
let _closing = false;
async function attemptClose() {
if (_closing) return;
_closing = true;
try {
const validity = isFormValid();
if (!validity.valid) {
if (isEditMode.value) {
toast.show(`${validity.reason} — change discarded`, "warning");
}
// Create mode + invalid: silent close. Nothing was committed.
emit("close");
return;
}
await save();
emit("close");
} finally {
_closing = false;
}
}
async function save() {
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
: undefined;
saving.value = true;
try {
if (isEditMode.value && props.event) {
const payload: EventUpdatePayload = {
title: title.value.trim(),
start_dt,
end_dt,
all_day: allDay.value,
description: description.value,
location: location.value,
color: color.value,
project_id: projectId.value ?? undefined,
recurrence: recurrence.value || null,
};
const updated = await updateEvent(props.event.id, payload);
emit("updated", updated);
} else {
const payload: EventCreatePayload = {
title: title.value.trim(),
start_dt,
end_dt,
all_day: allDay.value,
description: description.value,
location: location.value,
color: color.value,
project_id: projectId.value ?? undefined,
recurrence: recurrence.value || undefined,
};
const created = await createEvent(payload);
emit("created", created);
}
} catch {
toast.show("Failed to save event", "error");
} finally {
saving.value = false;
}
}
async function doDelete() {
if (!props.event) return;
deleting.value = true;
try {
await deleteEvent(props.event.id);
toast.show("Event deleted", "success");
emit("deleted", props.event.id);
} catch {
toast.show("Failed to delete event", "error");
deleting.value = false;
}
}
</script>
<template>
<Teleport to="body">
<div class="modal-backdrop" @click.self="attemptClose">
<div class="modal-panel" role="dialog" aria-modal="true">
<!-- Header: trash + close (or inline delete-confirm) -->
<div class="modal-header">
<template v-if="!deleteConfirm">
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<div class="header-actions">
<button
v-if="isEditMode"
class="header-btn header-btn-danger"
@click="deleteConfirm = true"
title="Delete event"
aria-label="Delete event"
><Trash2 :size="16" /></button>
<button
class="header-btn"
@click="attemptClose"
title="Close"
aria-label="Close"
><X :size="16" /></button>
</div>
</template>
<template v-else>
<span class="delete-confirm-prompt">Delete this event?</span>
<div class="header-actions">
<button
type="button"
class="btn-danger"
:disabled="deleting"
@click="doDelete"
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
<button
type="button"
class="btn-confirm-cancel"
@click="deleteConfirm = false"
>No</button>
</div>
</template>
</div>
<!-- Body: form (scrolls if it gets long) -->
<form class="modal-form" @submit.prevent="attemptClose">
<!-- Title -->
<div class="form-field">
<label class="form-label">Title <span class="required">*</span></label>
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
</div>
<!-- All-day toggle -->
<div class="form-field form-field-row">
<label class="form-label form-label-inline">All day</label>
<button
type="button"
:class="['toggle-btn', { active: allDay }]"
@click="allDay = !allDay"
>{{ allDay ? "Yes" : "No" }}</button>
</div>
<!-- Start -->
<div class="form-field">
<label class="form-label">Start <span class="required">*</span></label>
<div class="dt-row">
<input v-model="startDate" type="date" class="form-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
</div>
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
</div>
<!-- End -->
<div class="form-field">
<label class="form-label">End</label>
<div class="dt-row">
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
</div>
</div>
<!-- Recurrence -->
<div class="form-field">
<label class="form-label">Repeat</label>
<select v-model="recurrencePreset" class="form-input">
<option value="none">Does not repeat</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
<option v-if="isCustomRecurrence" value="custom" disabled>Custom</option>
</select>
<p v-if="isCustomRecurrence" class="recurrence-custom-hint">
Custom rule: <code>{{ recurrence }}</code>
<br />
<span class="form-hint">Picking a preset will replace this rule.</span>
</p>
</div>
<!-- Location -->
<div class="form-field">
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
<input v-model="location" class="form-input" placeholder="Location" />
</div>
<!-- Description -->
<div class="form-field">
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
</div>
<!-- Color -->
<div class="form-field form-field-row">
<label class="form-label form-label-inline">Color</label>
<div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
</div>
</div>
<!-- Project -->
<div class="form-field">
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
<ProjectSelector v-model="projectId" />
</div>
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
matching the no-explicit-Save-button intent: Enter commits. -->
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
</form>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.modal-panel {
background: var(--color-surface, #1a1b1e);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
width: min(480px, 100%);
max-height: calc(100vh - 2.5rem);
display: flex;
flex-direction: column;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem 0.85rem 1.5rem;
border-bottom: 1px solid var(--color-border, #2a2b30);
background: var(--color-surface, #1a1b1e);
min-height: 3rem;
}
.modal-title {
font-size: 1.05rem;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.header-btn {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 0.4rem;
border-radius: 6px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.header-btn:hover {
background: var(--color-hover, rgba(255,255,255,0.06));
color: var(--color-text, #e8e9f0);
}
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
than Save would have been — destructive actions shouldn't loom. */
.header-btn-danger:hover {
background: var(--color-action-destructive);
color: #fff;
}
/* Inline delete-confirm prompt replaces the title row */
.delete-confirm-prompt {
font-size: 0.95rem;
color: var(--color-text, #e8e9f0);
font-weight: 500;
}
/* Form scrolls inside the panel when content overflows */
.modal-form {
padding: 1.25rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
overflow-y: auto;
}
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
.form-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-label-inline { flex-shrink: 0; margin: 0; }
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
.required { color: #f87171; }
.form-input {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text, #e8e9f0);
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
.form-input:focus { outline: none; border-color: var(--color-primary); }
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; }
.form-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.recurrence-custom-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.recurrence-custom-hint code {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 4px;
padding: 1px 5px;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.72rem;
color: var(--color-text, #e8e9f0);
}
.toggle-btn {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
border-radius: 6px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
.color-hex { flex: 1; }
.btn-clear-color {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 0.2rem 0.3rem;
font-size: 0.85rem;
}
/* Confirm-delete buttons (only shown during the inline confirm flow) */
.btn-danger {
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-confirm-cancel {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
path as X / Esc / backdrop. No visible Save button needed. */
.hidden-submit {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
</style>
-277
View File
@@ -1,277 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
interface ForecastDay {
day: string
condition: string
high: number
low: number
precip_probability: number | null
precip_mm: number | null
windspeed_max: number
precip_summary?: string
precip_peak_hour?: string
}
interface WeatherData {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
wind_unit?: string
precip_summary?: string | null
forecast: ForecastDay[]
}
const props = defineProps<{
weather: WeatherData | null
tempUnit?: string
}>()
function weatherIcon(condition: string): string {
const c = condition.toLowerCase()
if (c.includes('thunderstorm')) return '⛈️'
if (c.includes('hail')) return '🌨️'
if (c.includes('snow showers')) return '🌨️'
if (c.includes('snow')) return '❄️'
if (c.includes('rain showers: violent')) return '⛈️'
if (c.includes('rain showers')) return '🌦️'
if (c.includes('drizzle') || c.includes('rain')) return '🌧️'
if (c.includes('fog')) return '🌫️'
if (c.includes('overcast')) return '☁️'
if (c.includes('partly cloudy')) return '⛅'
if (c.includes('mainly clear')) return '🌤️'
if (c.includes('clear')) return '☀️'
return '🌡️'
}
const unit = computed(() => props.tempUnit ?? 'C')
const tempDelta = computed(() => {
const w = props.weather
if (!w || w.today_high == null || w.yesterday_high == null) return null
const diff = w.today_high - w.yesterday_high
if (Math.abs(diff) < 1) return 'Same as yesterday'
const dir = diff > 0 ? 'warmer' : 'cooler'
return `${Math.abs(diff)}° ${dir} than yesterday`
})
const fetchedAtLabel = computed(() => {
if (!props.weather?.fetched_at) return ''
try {
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
} catch {
return ''
}
})
function hasPrecip(day: ForecastDay): boolean {
return (day.precip_probability != null && day.precip_probability > 0) ||
(day.precip_mm != null && day.precip_mm > 0)
}
</script>
<template>
<div v-if="weather" class="weather-card">
<div class="weather-header">
<span class="weather-location">{{ weather.location }}</span>
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
</div>
<div class="weather-current">
<span class="weather-icon">{{ weatherIcon(weather.condition) }}</span>
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
<span class="weather-condition">{{ weather.condition }}</span>
</div>
<div class="weather-today" v-if="weather.today_high != null">
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div>
<div v-if="weather.precip_summary" class="weather-precip-summary">
💧 {{ weather.precip_summary }}
</div>
<table class="weather-forecast" v-if="weather.forecast.length">
<thead>
<tr>
<th></th>
<th></th>
<th>Hi / Lo</th>
<th>💧</th>
<th>💨 {{ weather.wind_unit ?? 'km/h' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="day in weather.forecast" :key="day.day">
<td class="forecast-day-name">{{ day.day }}</td>
<td class="forecast-icon">{{ weatherIcon(day.condition) }}</td>
<td class="forecast-temps">{{ day.high }}° / {{ day.low }}°</td>
<td class="forecast-precip" :class="{ 'forecast-precip--dry': !hasPrecip(day) }">
<template v-if="day.precip_summary">
<span class="precip-detail" :title="day.precip_summary">
{{ day.precip_probability }}%
<span v-if="day.precip_peak_hour" class="precip-peak">{{ day.precip_peak_hour }}</span>
</span>
</template>
<template v-else-if="day.precip_probability != null && day.precip_probability > 0">{{ day.precip_probability }}%</template>
<template v-else-if="day.precip_mm != null && day.precip_mm > 0">{{ day.precip_mm.toFixed(1) }}mm</template>
<template v-else>&mdash;</template>
</td>
<td class="forecast-wind">{{ day.windspeed_max }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="weather-card weather-unavailable">
Weather data unavailable will retry at next slot.
</div>
</template>
<style scoped>
.weather-card {
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
font-size: 0.9rem;
container-type: inline-size;
}
.weather-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.weather-location {
font-weight: 600;
font-size: 0.95rem;
}
.weather-fetched-at {
color: var(--color-text-muted);
font-size: 0.78rem;
}
.weather-current {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.weather-icon {
font-size: clamp(1.5rem, 5cqi, 2.5rem);
line-height: 1;
}
.weather-temp {
font-size: clamp(1.5rem, 5cqi, 2.5rem);
font-weight: 700;
line-height: 1;
}
.weather-condition {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.weather-today {
color: var(--color-text-secondary);
margin-bottom: 0.25rem;
font-size: 0.85rem;
}
.weather-delta {
color: var(--color-text-muted);
font-size: 0.82rem;
}
.weather-precip-summary {
color: var(--color-text-secondary);
font-size: 0.83rem;
margin-bottom: 0.75rem;
padding: 0.35rem 0.5rem;
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
border-radius: var(--radius-sm, 6px);
border-left: 2px solid color-mix(in srgb, var(--color-primary) 40%, transparent);
}
.weather-forecast {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
border-top: 1px solid var(--color-border);
font-size: 0.8rem;
}
.weather-forecast thead th {
font-size: 0.68rem;
font-weight: 600;
color: var(--color-text-muted);
text-align: right;
padding: 0.5rem 0.4rem 0.25rem;
white-space: nowrap;
}
.weather-forecast thead th:first-child,
.weather-forecast thead th:nth-child(2) {
text-align: left;
}
.weather-forecast tbody td {
padding: 0.3rem 0.4rem;
white-space: nowrap;
vertical-align: middle;
}
.forecast-day-name {
font-weight: 600;
}
.forecast-icon {
font-size: clamp(0.9rem, 3cqi, 1.3rem);
line-height: 1;
}
.forecast-temps {
text-align: right;
}
.forecast-precip {
text-align: right;
color: var(--color-text-muted);
}
.forecast-precip--dry {
opacity: 0.35;
}
.precip-detail {
display: inline-flex;
align-items: baseline;
gap: 0.3em;
}
.precip-peak {
font-size: 0.7rem;
color: var(--color-text-muted);
opacity: 0.8;
}
.forecast-wind {
text-align: right;
color: var(--color-text-muted);
}
.weather-unavailable {
color: var(--color-text-muted);
}
</style>
-5
View File
@@ -108,11 +108,6 @@ const router = createRouter({
name: "shared-with-me",
component: () => import("@/views/SharedWithMeView.vue"),
},
{
path: "/calendar",
name: "calendar",
component: () => import("@/views/CalendarView.vue"),
},
{
path: "/settings",
name: "settings",
+1 -2
View File
@@ -31,7 +31,6 @@ export const useNotesStore = defineStore("notes", () => {
project_id?: number | null;
milestone_id?: number | null;
note_type?: string;
metadata?: Record<string, string> | null;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
@@ -43,7 +42,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type" | "metadata">>
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id" | "milestone_id" | "note_type">>
): Promise<Note> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
+1 -2
View File
@@ -3,7 +3,7 @@ import type { System } from "@/api/systems";
export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled";
export type TaskPriority = "none" | "low" | "medium" | "high";
export type TaskKind = "work" | "plan" | "issue";
export type NoteType = "note" | "person" | "place" | "list" | "process";
export type NoteType = "note" | "process";
export interface Note {
id: number;
@@ -28,7 +28,6 @@ export interface Note {
task_kind?: TaskKind;
systems?: System[];
arose_from_id?: number | null;
metadata: Record<string, string>;
created_at: string;
updated_at: string;
}
-763
View File
@@ -1,763 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useToastStore } from "@/stores/toast";
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
import { MapPin } from "lucide-vue-next";
const toast = useToastStore();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Slide-over state
const slideOverEvent = ref<EventEntry | null>(null);
const slideOverOpen = ref(false);
const slideOverDate = ref<string>("");
function openCreate(date: string) {
slideOverEvent.value = null;
slideOverDate.value = date;
slideOverOpen.value = true;
}
function openEdit(event: EventEntry) {
slideOverEvent.value = event;
slideOverDate.value = "";
slideOverOpen.value = true;
}
function closeSlideOver() {
slideOverOpen.value = false;
}
// Event entry cache keyed by id
const eventCache = new Map<number, EventEntry>();
function toFcEvent(e: EventEntry) {
// For all-day events pass date-only strings so FullCalendar never shifts
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
return {
id: String(e.id),
title: e.title,
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
allDay: e.all_day,
backgroundColor: e.color || undefined,
borderColor: e.color || undefined,
extendedProps: { entryId: e.id },
};
}
// ── Upcoming events list ───────────────────────────────────────────────────
const upcomingEvents = ref<EventEntry[]>([]);
async function loadUpcoming() {
const now = new Date();
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
try {
const entries = await listEvents(now.toISOString(), end.toISOString());
upcomingEvents.value = entries.sort(
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
);
} catch { /* silent */ }
}
onMounted(loadUpcoming);
// ── Month/year picker ──────────────────────────────────────────────────────
const currentViewYear = ref(new Date().getFullYear());
const currentViewMonth = ref(new Date().getMonth());
const pickerOpen = ref(false);
const pickerYear = ref(new Date().getFullYear());
const pickerStyle = ref<Record<string, string>>({});
const pickerEl = ref<HTMLElement | null>(null);
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
function handleDatesSet(arg: { view: { currentStart: Date } }) {
const d = arg.view.currentStart;
currentViewYear.value = d.getFullYear();
currentViewMonth.value = d.getMonth();
}
function jumpTo(year: number, month: number) {
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
pickerOpen.value = false;
}
// ── Event popover ──────────────────────────────────────────────────────────
const popover = ref<EventEntry | null>(null);
const popoverStyle = ref<Record<string, string>>({});
const popoverEl = ref<HTMLElement | null>(null);
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
popover.value = entry;
nextTickPositionPopover(clickEvent);
}
function nextTickPositionPopover(clickEvent: MouseEvent) {
// Position after DOM update
requestAnimationFrame(() => {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pw = 280;
const ph = 220; // approximate
let left = clickEvent.clientX + 8;
let top = clickEvent.clientY + 8;
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
popoverStyle.value = {
position: "fixed",
left: `${Math.max(8, left)}px`,
top: `${Math.max(8, top)}px`,
zIndex: "9999",
};
});
}
function closePopover() {
popover.value = null;
}
function onPopoverEdit() {
if (popover.value) {
openEdit(popover.value);
closePopover();
}
}
// Close popover / open picker on outside or title click
function onDocClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Title click → toggle month/year picker
const titleEl = target.closest(".fc-toolbar-title");
if (titleEl) {
if (!pickerOpen.value) {
pickerYear.value = currentViewYear.value;
const rect = titleEl.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
pickerStyle.value = {
position: "fixed",
top: `${rect.bottom + 6}px`,
left: `${left}px`,
zIndex: "9999",
};
}
pickerOpen.value = !pickerOpen.value;
return;
}
// Close picker on outside click
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
pickerOpen.value = false;
}
// Close event popover on outside click
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
closePopover();
}
}
function onCalendarChanged() {
calendarRef.value?.getApi().refetchEvents();
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
document.addEventListener("scribe:calendar-changed", onCalendarChanged);
});
onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
});
// ── Calendar callbacks ─────────────────────────────────────────────────────
async function loadEvents(
fetchInfo: { startStr: string; endStr: string },
successCallback: (events: object[]) => void,
failureCallback: (error: Error) => void,
) {
try {
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
eventCache.clear();
for (const e of entries) eventCache.set(e.id, e);
successCallback(entries.map(toFcEvent));
loadUpcoming();
} catch (err) {
failureCallback(err instanceof Error ? err : new Error(String(err)));
}
}
function handleDateClick(arg: DateClickArg) {
closePopover();
openCreate(arg.dateStr);
}
function handleEventClick(arg: EventClickArg) {
const id = arg.event.extendedProps.entryId as number;
const entry = eventCache.get(id);
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
}
async function handleEventDrop(arg: EventDropArg) {
const id = arg.event.extendedProps.entryId as number;
const start_dt = arg.event.startStr;
const end_dt = arg.event.endStr || undefined;
try {
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
eventCache.set(id, updated);
loadUpcoming();
} catch {
arg.revert();
toast.show("Failed to move event", "error");
}
}
async function handleEventResize(arg: EventResizeDoneArg) {
const id = arg.event.extendedProps.entryId as number;
const start_dt = arg.event.startStr;
const end_dt = arg.event.endStr || undefined;
try {
const updated = await updateEvent(id, { start_dt, end_dt });
eventCache.set(id, updated);
loadUpcoming();
} catch {
arg.revert();
toast.show("Failed to resize event", "error");
}
}
function onCreated(entry: EventEntry) {
eventCache.set(entry.id, entry);
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
closeSlideOver();
loadUpcoming();
}
function onUpdated(entry: EventEntry) {
eventCache.set(entry.id, entry);
const api = calendarRef.value?.getApi();
if (api) {
const existing = api.getEventById(String(entry.id));
if (existing) {
existing.remove();
api.addEvent(toFcEvent(entry));
}
}
closeSlideOver();
loadUpcoming();
}
function onDeleted(id: number) {
eventCache.delete(id);
const api = calendarRef.value?.getApi();
if (api) api.getEventById(String(id))?.remove();
closeSlideOver();
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
}
const calendarOptions: CalendarOptions = {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
timeZone: "local",
editable: true,
selectable: false,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
},
events: loadEvents,
datesSet: handleDatesSet,
dateClick: handleDateClick,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
height: "auto",
};
// Group upcoming events by day label
const upcomingGrouped = computed(() => {
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
for (const e of upcomingEvents.value) {
const label = fmtDayLabel(e.start_dt);
const existing = groups.find((g) => g.label === label);
if (existing) {
existing.events.push(e);
} else {
groups.push({ label, date: e.start_dt, events: [e] });
}
}
return groups;
});
</script>
<template>
<div class="calendar-view">
<div class="cal-header">
<h1 class="cal-title">Calendar</h1>
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
+ New Event
</button>
</div>
<div class="fc-wrapper">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
<!-- Upcoming events strip -->
<div v-if="upcomingEvents.length" class="upcoming-section">
<h2 class="upcoming-title">Upcoming</h2>
<div class="upcoming-groups">
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
<div class="upcoming-day-label">{{ group.label }}</div>
<div class="upcoming-cards">
<div
v-for="ev in group.events"
:key="ev.id"
class="upcoming-card"
:style="ev.color ? { '--ev-color': ev.color } : {}"
@click="openEdit(ev)"
>
<div class="upcoming-card-accent"></div>
<div class="upcoming-card-body">
<div class="upcoming-card-title">{{ ev.title }}</div>
<div class="upcoming-card-time">
<template v-if="ev.all_day">All day</template>
<template v-else>
{{ fmtTime(ev.start_dt) }}
<span v-if="ev.end_dt"> {{ fmtTime(ev.end_dt) }}</span>
</template>
</div>
<div v-if="ev.location" class="upcoming-card-meta">
<MapPin :size="16" style="flex-shrink:0" />
{{ ev.location }}
</div>
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Event popover -->
<Teleport to="body">
<div
v-if="popover"
ref="popoverEl"
class="event-popover"
:style="popoverStyle"
>
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
<div class="popover-content">
<div class="popover-title">{{ popover.title }}</div>
<div class="popover-time">
<template v-if="popover.all_day">
{{ fmtDateTime(popover.start_dt, true) }}
</template>
<template v-else>
{{ fmtDateTime(popover.start_dt, false) }}
<span v-if="popover.end_dt"> {{ fmtTime(popover.end_dt) }}</span>
</template>
</div>
<div v-if="popover.location" class="popover-meta">
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
{{ popover.location }}
</div>
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
<div class="popover-actions">
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- Month/year picker -->
<Teleport to="body">
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
<div class="picker-year-row">
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year"></button>
<span class="picker-year-label">{{ pickerYear }}</span>
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year"></button>
</div>
<div class="picker-months">
<button
v-for="(name, i) in MONTH_NAMES"
:key="i"
class="picker-month"
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
@click="jumpTo(pickerYear, i)"
>{{ name }}</button>
</div>
</div>
</Teleport>
<EventSlideOver
v-if="slideOverOpen"
:event="slideOverEvent"
:initial-date="slideOverDate"
@close="closeSlideOver"
@created="onCreated"
@updated="onUpdated"
@deleted="onDeleted"
/>
</div>
</template>
<style scoped>
.calendar-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem 1.5rem 3rem;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.cal-title {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
/* New event: Moss action-primary — workflow action, not a brand moment */
.btn-new-event {
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-new-event:hover { background: var(--color-action-primary-hover); }
.fc-wrapper {
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: var(--radius-lg, 18px);
padding: 1rem;
overflow: hidden;
}
/* FullCalendar dark theme overrides */
:deep(.fc) {
color: var(--color-text, #e8e9f0);
font-family: inherit;
}
:deep(.fc-toolbar-title) {
/* FullCalendar renders this as <h2>, which would otherwise pick up the
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
"Fraunces only at ≥18px" threshold, so explicit Inter. */
font-family: 'Inter', system-ui, sans-serif;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: background 0.15s;
user-select: none;
}
:deep(.fc-toolbar-title:hover) {
background: rgba(255,255,255,0.07);
}
:deep(.fc-button) {
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
font-size: 0.82rem;
padding: 0.3rem 0.7rem;
box-shadow: none;
}
:deep(.fc-button:hover),
:deep(.fc-button-active) {
background: var(--color-primary) !important;
border-color: var(--color-primary) !important;
color: #fff !important;
}
:deep(.fc-button:focus) { box-shadow: none !important; }
:deep(.fc-daygrid-day-number),
:deep(.fc-col-header-cell-cushion) {
color: var(--color-text-muted, #888);
text-decoration: none;
font-size: 0.82rem;
}
:deep(.fc-daygrid-day.fc-day-today) {
background: var(--color-primary-tint);
}
:deep(.fc-event) {
border-radius: 4px;
border: none;
padding: 1px 4px;
font-size: 0.78rem;
cursor: pointer;
}
:deep(.fc-event-main) { color: #fff; }
:deep(.fc-event:not([style*="background"])) {
background: var(--color-primary);
}
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-scrollgrid td),
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-daygrid-day) { cursor: pointer; }
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
/* ── Month/year picker ──────────────────────────────────────────────────── */
.month-picker {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 280px;
padding: 12px 14px;
}
.picker-year-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.picker-year-label {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text, #e8e9f0);
}
.picker-year-btn {
background: none;
border: 1px solid var(--color-border, #2a2b30);
border-radius: 6px;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 2px 10px;
font-size: 1rem;
line-height: 1.4;
transition: color 0.15s, border-color 0.15s;
}
.picker-year-btn:hover {
color: var(--color-text, #e8e9f0);
border-color: rgba(255,255,255,0.25);
}
.picker-months {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.picker-month {
padding: 7px 4px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--color-text, #e8e9f0);
cursor: pointer;
font-size: 0.84rem;
text-align: center;
transition: background 0.12s, color 0.12s;
}
.picker-month:hover {
background: rgba(255,255,255,0.08);
}
.picker-month.active {
background: rgba(91, 74, 138,0.22);
color: var(--color-primary);
font-weight: 500;
}
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
.upcoming-section {
margin-top: 2rem;
}
.upcoming-title {
font-size: 1rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.78rem;
margin: 0 0 1rem;
}
.upcoming-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.upcoming-day-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.upcoming-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upcoming-card {
display: flex;
align-items: stretch;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.upcoming-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
background: var(--color-bg-secondary);
}
.upcoming-card-accent {
width: 4px;
flex-shrink: 0;
background: var(--ev-color, #5B4A8A);
}
.upcoming-card-body {
padding: 0.6rem 0.85rem;
flex: 1;
min-width: 0;
}
.upcoming-card-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text, #e8e9f0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-card-time {
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.15rem;
}
.upcoming-card-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.25rem;
}
.upcoming-card-desc {
font-size: 0.8rem;
color: var(--color-text-secondary, #aaa);
margin-top: 0.3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Event popover ──────────────────────────────────────────────────────── */
.event-popover {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
width: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover-accent {
height: 4px;
background: var(--color-primary);
}
.popover-content {
padding: 0.85rem 1rem 0.75rem;
}
.popover-title {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text, #e8e9f0);
margin-bottom: 0.35rem;
}
.popover-time {
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-desc {
font-size: 0.82rem;
color: var(--color-text-secondary, #aaa);
line-height: 1.45;
margin-bottom: 0.6rem;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.popover-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
}
.popover-btn {
flex: 1;
padding: 0.35rem 0;
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.popover-btn:hover { opacity: 0.85; }
.popover-btn--edit {
background: var(--color-primary);
color: #fff;
}
.popover-btn--close {
background: var(--color-input-bg, var(--color-bg));
color: var(--color-text-muted, #888);
border: 1px solid var(--color-border, #2a2b30);
}
</style>
+1 -27
View File
@@ -11,13 +11,11 @@ interface ActiveProject {
milestones: MilestoneBlock[]; no_milestone: TaskRow[];
}
interface DoneItem { id: number; title: string; project_title: string | null; completed_at: string }
interface UpcomingEvent { id: number; title: string; start_dt: string | null; all_day: boolean }
interface WeekStats { completed_this_week: number; open_total: number; in_progress: number; active_plans: number }
interface IssueRow { id: number; title: string; status: string; priority: string; project_id: number | null; project_title: string | null }
interface DashboardData {
active_projects: ActiveProject[];
recently_completed: DoneItem[];
upcoming_events: UpcomingEvent[];
open_issues: IssueRow[];
week_stats: WeekStats;
}
@@ -31,19 +29,11 @@ onMounted(async () => {
try {
data.value = await apiGet<DashboardData>("/api/dashboard");
} catch {
data.value = { active_projects: [], recently_completed: [], upcoming_events: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
data.value = { active_projects: [], recently_completed: [], open_issues: [], week_stats: { completed_this_week: 0, open_total: 0, in_progress: 0, active_plans: 0 } };
} finally {
loading.value = false;
}
});
function fmtEvent(e: UpcomingEvent): string {
if (!e.start_dt) return "";
const d = new Date(e.start_dt);
const day = d.toLocaleDateString(undefined, { weekday: "short" });
if (e.all_day) return day;
return `${day} ${d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;
}
</script>
<template>
@@ -150,17 +140,6 @@ function fmtEvent(e: UpcomingEvent): string {
</div>
</template>
<div class="dash-label">Upcoming · 7 days</div>
<div class="rail-card">
<template v-if="data.upcoming_events.length">
<div v-for="e in data.upcoming_events" :key="e.id" class="evt-row">
<span class="evt-when">{{ fmtEvent(e) }}</span>
<span class="evt-title">{{ e.title }}</span>
</div>
</template>
<p v-else class="rail-empty">Nothing scheduled.</p>
</div>
<div class="dash-label">This week</div>
<div class="rail-card stats">
<span> {{ data.week_stats.completed_this_week }} done</span>
@@ -241,11 +220,6 @@ function fmtEvent(e: UpcomingEvent): string {
.proj-more { display: inline-block; margin-top: 0.6rem; font-size: 0.78rem; color: var(--color-primary); text-decoration: none; }
.rail-card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px; padding: 0.7rem 0.85rem; margin-bottom: 1.25rem; }
.evt-row { display: flex; gap: 0.6rem; padding: 0.3rem 0; border-bottom: 1px solid var(--color-border); font-size: 0.85rem; }
.evt-row:last-child { border-bottom: none; }
.evt-when { color: var(--color-muted); white-space: nowrap; }
.evt-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-empty { margin: 0; color: var(--color-muted); font-size: 0.85rem; }
.stats { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.9rem; }
.stats-sub { color: var(--color-muted); font-size: 0.78rem; }
.proj-stats { display: flex; flex-direction: column; gap: 0.1rem; padding: 0.4rem 0.45rem; }
+13 -265
View File
@@ -1,15 +1,11 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPatch, listEvents } from "@/api/client";
import { fmtCompact } from "@/utils/dateFormat";
import { apiGet } from "@/api/client";
import GraphView from "@/views/GraphView.vue";
import {
FileText,
CheckSquare,
User,
MapPin,
List,
Workflow,
Search,
Share2,
@@ -24,28 +20,13 @@ const router = useRouter();
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list" | "task" | "process";
note_type: "note" | "task" | "process";
title: string;
snippet: string;
tags: string[];
project_id: number | null;
metadata: Record<string, string>;
created_at: string;
updated_at: string;
// type-specific
relationship?: string;
email?: string;
phone?: string;
birthday?: string;
organization?: string;
address?: string;
hours?: string;
website?: string;
category?: string;
item_count?: number;
checked_count?: number;
list_items?: { text: string; checked: boolean }[];
body?: string;
// Task-specific
status?: string;
priority?: string;
@@ -53,16 +34,9 @@ interface KnowledgeItem {
task_kind?: "work" | "plan";
}
interface UpcomingEvent {
id: number;
title: string;
start_dt: string;
all_day: boolean;
}
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task" | "plan" | "process">("");
const activeType = ref<"" | "note" | "task" | "plan" | "process">("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
@@ -70,8 +44,8 @@ let searchDebounce: ReturnType<typeof setTimeout> | null = null;
// ─── Type counts ──────────────────────────────────────────────────────────────
interface KnowledgeCounts { note: number; person: number; place: number; list: number; task: number; plan: number; process: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, person: 0, place: 0, list: 0, task: 0, plan: 0, process: 0, total: 0 });
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
const typeCounts = ref<KnowledgeCounts>({ note: 0, task: 0, plan: 0, process: 0, total: 0 });
async function fetchCounts() {
try {
@@ -212,26 +186,17 @@ watch(activeTag, () => { fetchCounts(); resetAndReobserve(); });
// ─── Today bar ────────────────────────────────────────────────────────────────
const upcomingEvents = ref<UpcomingEvent[]>([]);
const overdueCount = ref(0);
async function fetchTodayBar() {
try {
const now = new Date();
const end = new Date(now.getTime() + 7 * 86_400_000);
const [events, taskData] = await Promise.all([
listEvents(now.toISOString(), end.toISOString()).catch(() => [] as UpcomingEvent[]),
apiGet<{ total: number }>(
const taskData = await apiGet<{ total: number }>(
`/api/tasks?status=todo&status=in_progress&overdue=true&limit=1`
).catch(() => ({ total: 0 })),
]);
upcomingEvents.value = events.slice(0, 3);
).catch(() => ({ total: 0 }));
overdueCount.value = taskData.total ?? 0;
} catch { /* silent */ }
}
const formatEventDate = fmtCompact
// ─── Graph panel ──────────────────────────────────────────────────────────────
const _GRAPH_OPEN_KEY = 'fa_knowledge_graph_open'
@@ -250,40 +215,6 @@ function toggleGraphExpand() {
localStorage.setItem(_GRAPH_EXP_KEY, String(graphExpanded.value))
}
// ─── List item toggle ─────────────────────────────────────────────────────────
async function toggleListItem(item: KnowledgeItem, index: number) {
if (!item.list_items || item.body === undefined) return;
// Optimistic update
const newChecked = !item.list_items[index].checked;
item.list_items[index].checked = newChecked;
item.checked_count = item.list_items.filter(i => i.checked).length;
// Rebuild the body by replacing the targeted checkbox line
let listIdx = 0;
const newBody = (item.body).split('\n').map(line => {
const stripped = line.trimStart();
if (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] ')) {
if (listIdx === index) {
const indent = line.length - stripped.length;
const newLine = (' '.repeat(indent)) + (newChecked ? '- [x] ' : '- [ ] ') + item.list_items![index].text;
listIdx++;
return newLine;
}
listIdx++;
}
return line;
}).join('\n');
item.body = newBody;
try {
await apiPatch(`/api/notes/${item.id}`, { body: newBody });
} catch {
reset(); // revert on error
}
}
// ─── Navigation helpers ───────────────────────────────────────────────────────
function isOverdue(item: KnowledgeItem): boolean {
@@ -352,22 +283,9 @@ onUnmounted(() => {
<div class="knowledge-root" :class="{ 'graph-open': graphOpen, 'graph-expanded': graphExpanded && graphOpen }">
<!-- Today bar -->
<div class="today-bar">
<div class="today-events">
<span v-if="upcomingEvents.length === 0" class="today-empty">No upcoming events</span>
<router-link
v-for="ev in upcomingEvents"
:key="ev.id"
:to="`/calendar`"
class="today-event-chip"
>
<span class="chip-dot"></span>
{{ ev.title }}
<span class="chip-date">{{ formatEventDate(ev.start_dt, ev.all_day) }}</span>
</router-link>
</div>
<div v-if="overdueCount > 0" class="today-bar">
<div class="today-actions">
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
<router-link to="/tasks" class="overdue-badge">
{{ overdueCount }} overdue
</router-link>
</div>
@@ -392,18 +310,6 @@ onUnmounted(() => {
<CheckSquare :size="16" />
Task
</button>
<button @click="createNew('person')">
<User :size="16" />
Person
</button>
<button @click="createNew('place')">
<MapPin :size="16" />
Place
</button>
<button @click="createNew('list')">
<List :size="16" />
List
</button>
<button @click="createNew('process')">
<Workflow :size="16" />
Process
@@ -422,11 +328,11 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button>
<button
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['person','People','person'],['place','Places','place'],['list','Lists','list'],['process','Processes','process']] as [string,string,string][])"
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['plan','Plans','plan'],['process','Processes','process']] as [string,string,string][])"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task' | 'plan' | 'process')"
@click="activeType = (val as '' | 'note' | 'task' | 'plan' | 'process')"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
@@ -496,54 +402,15 @@ onUnmounted(() => {
<!-- Type badge -->
<span class="type-badge" :class="item.task_kind === 'plan' ? 'badge--plan' : `badge--${item.note_type}`">
<span v-if="item.note_type === 'note'">Note</span>
<span v-else-if="item.note_type === 'person'">Person</span>
<span v-else-if="item.note_type === 'place'">Place</span>
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
<span v-else-if="item.note_type === 'process'">Process</span>
<span v-else>List</span>
</span>
<div class="k-card-body">
<div class="k-card-title">{{ item.title }}</div>
<!-- Person specifics -->
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
<!-- Place specifics -->
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
<!-- List specifics -->
<div v-else-if="item.note_type === 'list'" class="k-card-list" @click.stop>
<label
v-for="(li, idx) in (item.list_items ?? []).slice(0, 6)"
:key="idx"
class="list-item-row"
>
<input
type="checkbox"
:checked="li.checked"
@change="toggleListItem(item, idx)"
/>
<span :class="{ 'list-item-done': li.checked }">{{ li.text }}</span>
</label>
<div v-if="(item.list_items?.length ?? 0) > 6" class="list-item-more">
+{{ (item.list_items?.length ?? 0) - 6 }} more
</div>
<span class="list-progress" style="margin-top: 6px;">
<span class="list-progress-bar" :style="{ width: item.item_count ? `${Math.round(((item.checked_count ?? 0) / item.item_count) * 100)}%` : '0%' }"></span>
</span>
</div>
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div v-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
@@ -631,35 +498,6 @@ onUnmounted(() => {
font-size: 0.82rem;
flex-wrap: wrap;
}
.today-events {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.today-empty {
color: var(--color-muted);
}
.today-event-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 20px;
background: rgba(91, 74, 138, 0.1);
border: 1px solid rgba(91, 74, 138, 0.2);
color: var(--color-text);
text-decoration: none;
transition: background 0.15s;
}
.today-event-chip:hover { background: rgba(91, 74, 138, 0.18); }
.chip-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: #5B4A8A;
flex-shrink: 0;
}
.chip-date { color: var(--color-muted); font-size: 0.78rem; }
.today-actions { display: flex; align-items: center; gap: 10px; }
.overdue-badge {
padding: 2px 9px;
@@ -929,14 +767,10 @@ onUnmounted(() => {
/* Type-specific card DNA */
.k-card--note { border-color: rgba(91, 74, 138, 0.20); }
.k-card--task { border-color: rgba(212, 160, 23, 0.18); }
.k-card--person { border-color: rgba(16, 185, 129, 0.18); }
.k-card--place { border-color: rgba(245, 158, 11, 0.18); }
.k-card--list { border-color: rgba(56, 189, 248, 0.18); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before,
.k-card--list::before {
.k-card--task::before {
content: '';
position: absolute;
top: 0;
@@ -952,25 +786,6 @@ onUnmounted(() => {
right: 0;
background: linear-gradient(90deg, #d4a017, #fbbf24);
}
.k-card--list::before {
right: 0;
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
}
/* Corner accents for entity types */
.k-card--person::after,
.k-card--place::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 60px;
border-radius: 0 14px 0 60px;
pointer-events: none;
}
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
/* Type badge */
.type-badge {
@@ -985,9 +800,6 @@ onUnmounted(() => {
letter-spacing: 0.04em;
}
.badge--note { background: rgba(91, 74, 138,0.15); color: #7A6DA8; }
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
.badge--task { background: rgba(212,160,23,0.15); color: #fbbf24; }
.badge--plan { background: rgba(99,102,241,0.18); color: #818cf8; }
@@ -1012,70 +824,6 @@ onUnmounted(() => {
line-height: 1.45;
margin: 0;
}
.k-card-meta {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.8rem;
}
.meta-chip {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
background: rgba(255,255,255,0.06);
font-size: 0.75rem;
width: fit-content;
}
.meta-muted { color: var(--color-muted); }
/* List card items */
.k-card-list {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 2px;
}
.list-item-row {
display: flex;
align-items: baseline;
gap: 6px;
font-size: 0.82rem;
color: var(--color-text);
cursor: pointer;
line-height: 1.4;
}
.list-item-row input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
}
.list-item-done {
text-decoration: line-through;
color: var(--color-text-muted);
}
.list-item-more {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 2px;
}
/* List progress bar */
.list-progress {
display: block;
height: 4px;
border-radius: 2px;
background: rgba(255,255,255,0.08);
overflow: hidden;
margin-bottom: 4px;
}
.list-progress-bar {
display: block;
height: 100%;
background: #38bdf8;
border-radius: 2px;
transition: width 0.3s;
}
.k-card-footer {
display: flex;
align-items: center;
+9 -361
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import type { NoteType } from "@/types/note";
import { useNotesStore } from "@/stores/notes";
@@ -34,85 +34,10 @@ const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const noteType = ref<NoteType>("note");
const entityMeta = reactive<Record<string, string>>({});
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
const sidebarOpen = ref(true);
const notesExpanded = ref(false);
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(_index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
const editorRef = ref<InstanceType<typeof TiptapEditor> | null>(null);
const titleRef = ref<HTMLInputElement | null>(null);
@@ -127,9 +52,6 @@ const isEditing = computed(() => noteId.value !== null);
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
case 'process': return 'Process name';
default: return 'Title';
}
@@ -276,7 +198,6 @@ let savedTags: string[] = [];
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedNoteType: NoteType = "note";
let savedEntityMeta: Record<string, string> = {};
function markDirty() {
dirty.value =
@@ -285,8 +206,7 @@ function markDirty() {
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
noteType.value !== savedNoteType ||
JSON.stringify(entityMeta) !== JSON.stringify(savedEntityMeta);
noteType.value !== savedNoteType;
}
function onBodyUpdate(newVal: string) {
@@ -304,31 +224,19 @@ onMounted(async () => {
projectId.value = store.currentNote.project_id ?? null;
milestoneId.value = store.currentNote.milestone_id ?? null;
noteType.value = (store.currentNote.note_type as NoteType) || "note";
Object.assign(entityMeta, store.currentNote.metadata || {});
notesExpanded.value = !!(store.currentNote.body || '').trim();
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
}
} else {
// New note: read type from query param
const qt = route.query.type as string | undefined;
if (qt && ["note", "person", "place", "list"].includes(qt)) {
if (qt && ["note", "process"].includes(qt)) {
noteType.value = qt as NoteType;
}
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
}
// Restore pending draft if any
@@ -349,7 +257,7 @@ onMounted(async () => {
async function save() {
if (saving.value) return;
saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
const finalBody = body.value;
try {
if (isEditing.value) {
await store.updateNote(noteId.value!, {
@@ -359,7 +267,6 @@ async function save() {
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
metadata: { ...entityMeta },
});
savedTitle = title.value;
savedBody = body.value;
@@ -367,7 +274,6 @@ async function save() {
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
dirty.value = false;
toast.show("Note saved");
} else {
@@ -378,7 +284,6 @@ async function save() {
project_id: projectId.value,
milestone_id: milestoneId.value,
note_type: noteType.value,
metadata: { ...entityMeta },
});
dirty.value = false;
toast.show("Note created");
@@ -414,12 +319,12 @@ async function confirmDelete() {
async function doAutoSave() {
if (!isEditing.value || saving.value) return;
saving.value = true;
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
const finalBody = body.value;
try {
await store.updateNote(noteId.value!, {
title: title.value, body: finalBody, tags: tags.value,
project_id: projectId.value, milestone_id: milestoneId.value,
note_type: noteType.value, metadata: { ...entityMeta },
note_type: noteType.value,
});
savedTitle = title.value;
savedBody = body.value;
@@ -427,7 +332,6 @@ async function doAutoSave() {
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedNoteType = noteType.value;
savedEntityMeta = { ...entityMeta };
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -473,138 +377,8 @@ onUnmounted(() => assist.clearSelection());
<!-- Main column -->
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<!-- Person form -->
<template v-if="noteType === 'person'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- Place form -->
<template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- List builder -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '' : '' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- Process (prompt) editor -->
<template v-else-if="noteType === 'process'">
<template v-if="noteType === 'process'">
<label class="ef-label">Prompt</label>
<textarea
class="prompt-editor"
@@ -718,9 +492,7 @@ onUnmounted(() => assist.clearSelection());
<label class="sb-label">Type</label>
<select v-model="noteType" class="sb-select" @change="markDirty">
<option value="note">Note</option>
<option value="person">Person</option>
<option value="place">Place</option>
<option value="list">List</option>
<option value="process">Process</option>
</select>
</div>
@@ -1034,18 +806,7 @@ onUnmounted(() => assist.clearSelection());
letter-spacing: 0.05em;
}
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
/* ── Process editor ─────────────────────────────────────── */
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-size: 0.92rem;
@@ -1071,119 +832,6 @@ onUnmounted(() => assist.clearSelection());
.prompt-editor:focus {
border-color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow screen: sidebar collapses */
@media (max-width: 720px) {
.note-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
-134
View File
@@ -366,18 +366,6 @@ const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
caldav_username: "",
caldav_password: "",
caldav_calendar_name: "",
});
const savingCaldav = ref(false);
const caldavSaved = ref(false);
const testingCaldav = ref(false);
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
// SMTP settings (admin only)
const smtp = ref({
smtp_host: "",
@@ -483,14 +471,6 @@ onMounted(async () => {
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
// Load CalDAV settings
try {
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
caldav.value = { ...caldav.value, ...caldavConfig };
} catch {
// CalDAV not configured yet
}
// Check SearXNG status
try {
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
@@ -672,38 +652,6 @@ async function saveNotifications() {
}
}
async function saveCaldav() {
savingCaldav.value = true;
caldavSaved.value = false;
try {
await apiPut("/api/settings/caldav", caldav.value);
caldavSaved.value = true;
setTimeout(() => (caldavSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save CalDAV settings", "error");
} finally {
savingCaldav.value = false;
}
}
async function testCaldav() {
testingCaldav.value = true;
caldavTestResult.value = null;
try {
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
caldavTestResult.value = result;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
} else {
caldavTestResult.value = { success: false, error: "Connection test failed" };
}
} finally {
testingCaldav.value = false;
}
}
async function saveSmtp() {
savingSmtp.value = true;
smtpSaved.value = false;
@@ -1507,50 +1455,6 @@ function formatUserDate(iso: string): string {
<!-- Integrations -->
<div v-show="activeTab === 'integrations'" class="settings-grid">
<section class="settings-section full-width">
<h2>Calendar (CalDAV)</h2>
<p class="section-desc">
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
</p>
<div class="caldav-grid">
<div class="field">
<label for="caldav-url">CalDAV URL</label>
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
</div>
<div class="field">
<label for="caldav-username">Username</label>
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
</div>
<div class="field">
<label for="caldav-password">Password</label>
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
</div>
<div class="field">
<label for="caldav-calendar-name">Calendar Name</label>
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
<p class="field-hint">Optional. Exact calendar name to use.</p>
</div>
</div>
<div class="actions" style="margin-bottom: 1rem;">
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
{{ savingCaldav ? "Saving..." : "Save" }}
</button>
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
{{ testingCaldav ? "Testing..." : "Test Connection" }}
</button>
</div>
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
<template v-if="caldavTestResult.success">
{{ caldavTestResult.message }}
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
Calendars: {{ caldavTestResult.calendars.join(", ") }}
</div>
</template>
<template v-else>{{ caldavTestResult.error }}</template>
</div>
</section>
<section class="settings-section full-width">
<h2>Web Search (SearXNG)</h2>
<template v-if="searxngConfigured">
@@ -2712,41 +2616,6 @@ function formatUserDate(iso: string): string {
accent-color: var(--color-primary);
}
/* CalDAV grid */
.caldav-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.caldav-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.caldav-grid { grid-template-columns: 1fr; }
}
.test-result {
padding: 0.65rem 0.9rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
line-height: 1.4;
}
.test-result.success {
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
border: 1px solid var(--color-success);
color: var(--color-success);
}
.test-result.error {
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
border: 1px solid var(--color-danger);
color: var(--color-danger);
}
.test-calendars {
margin-top: 0.3rem;
font-size: 0.82rem;
opacity: 0.85;
}
/* Search test */
.url-chip {
font-size: 0.8rem;
@@ -2931,9 +2800,6 @@ function formatUserDate(iso: string): string {
.smtp-grid {
grid-template-columns: 1fr;
}
.caldav-grid {
grid-template-columns: 1fr;
}
}
/* Users panel */