This commit is contained in:
stan44 2026-02-27 21:07:41 -06:00
commit bcdf74d9e5
4 changed files with 860 additions and 98 deletions

View File

@ -2,6 +2,8 @@
export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
() => {};
export let onDateActivate: (payload: { year: number; month: number; day: number; key: string }) => void = () => {};
export let signalsByDate: Record<string, { count: number; hasTrigger: boolean; hasMood: boolean; hasOpenTodos: boolean }> = {};
const today = new Date();
let currentYear = today.getFullYear();
@ -31,6 +33,11 @@
currentMonth = next.getMonth();
}
function signalFor(cell: CalendarCell): { count: number; hasTrigger: boolean; hasMood: boolean; hasOpenTodos: boolean } | null {
const key = getDateKey(cell.year, cell.month, cell.day);
return signalsByDate[key] ?? null;
}
function changeMonth(offset: number) {
setViewDate(currentYear, currentMonth + offset);
}
@ -39,7 +46,14 @@
if (!cell.inMonth) {
setViewDate(cell.year, cell.month);
}
selectedDateKey = getDateKey(cell.year, cell.month, cell.day);
const key = getDateKey(cell.year, cell.month, cell.day);
selectedDateKey = key;
onDateActivate({
year: cell.year,
month: cell.month,
day: cell.day,
key
});
}
function getCalendarCells(year: number, month: number): CalendarCell[] {
@ -139,7 +153,17 @@
aria-label={`Day ${cell.day}`}
on:click={() => selectCell(cell)}
>
{cell.day}
<span class="day-number">{cell.day}</span>
{#if signalFor(cell)}
{#if signalFor(cell)!.count > 0}
<span class="entry-count">{signalFor(cell)!.count}</span>
{/if}
<span class="signals" aria-hidden="true">
{#if signalFor(cell)!.hasMood}<i class="signal mood"></i>{/if}
{#if signalFor(cell)!.hasTrigger}<i class="signal trigger"></i>{/if}
{#if signalFor(cell)!.hasOpenTodos}<i class="signal todo"></i>{/if}
</span>
{/if}
</button>
{/each}
</div>
@ -213,12 +237,63 @@
}
.calendar-cell {
height: 30px;
height: 36px;
border-radius: 7px;
border: 1px solid transparent;
font-size: 0.76rem;
color: var(--text-muted);
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.day-number {
line-height: 1;
}
.entry-count {
position: absolute;
top: 3px;
right: 3px;
min-width: 12px;
height: 12px;
padding: 0 3px;
border-radius: 999px;
background: var(--surface-3);
color: var(--text-primary);
font-size: 0.58rem;
line-height: 12px;
border: 1px solid var(--border-soft);
}
.signals {
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
display: inline-flex;
gap: 2px;
}
.signal {
width: 4px;
height: 4px;
border-radius: 999px;
display: inline-block;
}
.signal.mood {
background: #6ba7ff;
}
.signal.trigger {
background: #f08c6c;
}
.signal.todo {
background: #f2c266;
}
.calendar-cell:hover {

View File

@ -23,7 +23,65 @@
export let onDeleteDocument: (id: string) => void = () => {};
export let showLinkedBackButton = false;
export let onLinkedBack: () => void = () => {};
export let calendarItems: Array<{ id: string; label: string; initialContent: string }> = [];
export let calendarBusy = false;
export let calendarError = "";
export let previewOnly = true;
type CalendarCard = {
id: string;
label: string;
initialContent: string;
title: string;
summary: string;
hasTrigger: boolean;
hasMood: boolean;
hasOpenTodos: boolean;
};
function deriveSummary(content: string): string {
const lines = content.replace(/\r\n/g, "\n").split("\n");
let inFrontmatter = false;
let frontmatterDone = false;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!frontmatterDone && line === "---") {
inFrontmatter = !inFrontmatter;
if (!inFrontmatter) frontmatterDone = true;
continue;
}
if (inFrontmatter || !line) continue;
if (/^#/.test(line)) continue;
if (/^\*\*Date:\*\*/i.test(line)) continue;
if (/^Date:/i.test(line)) continue;
if (/^(Type:|Tags:)/i.test(line)) continue;
return line.length > 180 ? `${line.slice(0, 177)}...` : line;
}
return "No summary available.";
}
function deriveTitle(label: string, content: string): string {
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
if (heading) return heading;
return label?.trim() || "Untitled Entry";
}
function toCalendarCard(item: { id: string; label: string; initialContent: string }): CalendarCard {
const content = item.initialContent ?? "";
const lower = content.toLowerCase();
return {
...item,
title: deriveTitle(item.label, content),
summary: deriveSummary(content),
hasTrigger: lower.includes("!trigger") || lower.includes("#trigger") || lower.includes("#stress"),
hasMood: lower.includes("mental / emotional snapshot") || lower.includes("cognitive state"),
hasOpenTodos: /-\s*\[\s\]/.test(content)
};
}
$: calendarCards = calendarItems.map(toCalendarCard);
</script>
<main class="editor-panel" aria-label="Editor area">
@ -35,7 +93,39 @@
</div>
{/if}
{#if !openDocumentId}
{#if activeSection === "calendar"}
<section class="calendar-main" aria-label="Calendar timeline results">
<header class="calendar-main-header">
<h2>Filtered Entries</h2>
</header>
{#if calendarBusy}
<p class="calendar-copy">Loading timeline...</p>
{:else if calendarError}
<p class="calendar-copy is-error">{calendarError}</p>
{:else if calendarItems.length === 0}
<p class="calendar-copy">No entries matched the current filters.</p>
{:else}
<ul class="calendar-list">
{#each calendarCards as item}
<li class:is-active={item.id === openDocumentId}>
<button type="button" class="calendar-item-btn" on:click={() => onOpenDocument(item)}>
<div class="calendar-item-head">
<h3>{item.title}</h3>
<span class="calendar-date">{item.label}</span>
</div>
<p class="calendar-summary">{item.summary}</p>
<div class="calendar-badges">
{#if item.hasMood}<span class="badge mood">Mood</span>{/if}
{#if item.hasTrigger}<span class="badge trigger">Trigger</span>{/if}
{#if item.hasOpenTodos}<span class="badge todo">Open To-Dos</span>{/if}
</div>
</button>
</li>
{/each}
</ul>
{/if}
</section>
{:else if !openDocumentId}
<div class="editor-empty">
<span class="material-symbols-outlined empty-icon">edit_note</span>
<p>Select or create an item to get started</p>
@ -129,4 +219,121 @@
font-size: 0.88rem;
}
}
.calendar-main {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 8px;
display: flex;
flex-direction: column;
gap: 10px;
}
.calendar-main-header h2 {
font-size: 0.96rem;
font-weight: 600;
color: var(--text-primary);
}
.calendar-copy {
font-size: 0.84rem;
color: var(--text-dim);
}
.calendar-copy.is-error {
color: #e74c3c;
}
.calendar-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
}
.calendar-list li {
border-radius: 8px;
border: 1px solid var(--border-soft);
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
}
.calendar-list li:hover {
background: var(--bg-hover);
}
.calendar-list li.is-active {
border-color: var(--border-strong);
background: var(--bg-active);
}
.calendar-item-btn {
width: 100%;
text-align: left;
padding: 10px 12px;
color: var(--text-primary);
font-size: 0.86rem;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 7px;
}
.calendar-item-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.calendar-item-head h3 {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-date {
font-size: 0.74rem;
color: var(--text-dim);
white-space: nowrap;
}
.calendar-summary {
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.45;
}
.calendar-badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.badge {
font-size: 0.68rem;
border-radius: 999px;
border: 1px solid var(--border-soft);
padding: 2px 7px;
color: var(--text-dim);
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
}
.badge.mood {
border-color: color-mix(in srgb, #6ba7ff 40%, var(--border-soft) 60%);
color: #8dbbff;
}
.badge.trigger {
border-color: color-mix(in srgb, #f08c6c 40%, var(--border-soft) 60%);
color: #f5ad95;
}
.badge.todo {
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
color: #f4d690;
}
</style>

View File

@ -1,7 +1,8 @@
<script lang="ts">
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
import { sendCommand } from "$lib/backend/client";
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { entriesBusyStore, entriesStore } from "$lib/stores/entries";
import { entriesBusyStore, entriesStore, searchEntriesAsItems } from "$lib/stores/entries";
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
@ -12,6 +13,7 @@
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onEditItem: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
export let onDeleteItem: (doc: { id: string; label: string }) => void = () => {};
export let onCalendarStateChange: (state: { items: SidePanelItem[]; busy: boolean; error: string }) => void = () => {};
let showNewItemInput = false;
let newItemName = "";
@ -23,8 +25,29 @@
label: string;
initialContent: string;
};
type CalendarViewMode = "day" | "week" | "month";
type CalendarSortMode = "asc" | "desc";
type CalendarSignal = {
count: number;
hasTrigger: boolean;
hasMood: boolean;
hasOpenTodos: boolean;
};
type SavedCalendarView = {
id: string;
name: string;
viewMode: CalendarViewMode;
sortMode: CalendarSortMode;
query: string;
tags: string;
types: string;
checked: string;
unchecked: string;
startDate: string;
endDate: string;
};
let todoDocuments: SidePanelItem[] = [];
let customCalendarEntries: SidePanelItem[] = [];
let templateItems: SidePanelItem[] = [];
let templatesBusy = false;
let templateError = "";
@ -45,6 +68,28 @@
let calendarMonthLabel = new Date(calendarYear, calendarMonth, 1).toLocaleString(undefined, {
month: "long"
});
let calendarViewMode: CalendarViewMode = "month";
let calendarSortMode: CalendarSortMode = "desc";
let calendarQuery = "";
let calendarTags = "";
let calendarTypes = "";
let calendarChecked = "";
let calendarUnchecked = "";
let calendarStartDate = "";
let calendarEndDate = "";
let calendarTimelineItems: SidePanelItem[] = [];
let calendarSignals: Record<string, CalendarSignal> = {};
let calendarBusy = false;
let calendarError = "";
let calendarSavedViews: SavedCalendarView[] = [];
let showSaveViewInput = false;
let saveViewName = "";
let hasLoadedSavedViews = false;
let lastCalendarTimelineKey = "";
let lastCalendarSignalsKey = "";
let calendarTimelineDebounce: ReturnType<typeof setTimeout> | null = null;
const CALENDAR_SAVED_VIEWS_KEY = "journal.calendar.savedViews";
let selectedCalendarDate: { year: number; month: number; day: number; key: string } | null = {
year: today.getFullYear(),
month: today.getMonth(),
@ -58,18 +103,277 @@
calendarMonthLabel = payload.label;
}
function toMonthKey(year: number, month: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}`;
}
function toDateKey(year: number, month: number, day: number): string {
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function parseCsv(input: string): string[] {
return input
.split(",")
.map((token) => token.trim())
.filter(Boolean);
}
function parseDateLabel(value: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
const date = new Date(`${value}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
function toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function getActiveDateRange(): { startDate: string; endDate: string } {
if (calendarStartDate && calendarEndDate) {
return { startDate: calendarStartDate, endDate: calendarEndDate };
}
const selected = selectedCalendarDate ?? {
year: calendarYear,
month: calendarMonth,
day: 1
};
const selectedDate = new Date(selected.year, selected.month, selected.day);
if (calendarViewMode === "day") {
const date = toIsoDate(selectedDate);
return { startDate: date, endDate: date };
}
if (calendarViewMode === "week") {
const weekDay = (selectedDate.getDay() + 6) % 7;
const start = new Date(selectedDate);
start.setDate(selectedDate.getDate() - weekDay);
const end = new Date(start);
end.setDate(start.getDate() + 6);
return { startDate: toIsoDate(start), endDate: toIsoDate(end) };
}
const monthStart = new Date(calendarYear, calendarMonth, 1);
const monthEnd = new Date(calendarYear, calendarMonth + 1, 0);
return { startDate: toIsoDate(monthStart), endDate: toIsoDate(monthEnd) };
}
async function getDataDirectory(): Promise<string> {
const config = await sendCommand<{ dataDirectory?: string; DataDirectory?: string }>({
action: "config.get"
});
return (config.dataDirectory ?? config.DataDirectory ?? "").trim();
}
function getSignal(dateKey: string): CalendarSignal {
const existing = calendarSignals[dateKey];
if (existing) return existing;
return { count: 0, hasTrigger: false, hasMood: false, hasOpenTodos: false };
}
function upsertSignal(dateKey: string, next: CalendarSignal) {
calendarSignals = { ...calendarSignals, [dateKey]: next };
}
async function refreshCalendarSignals(): Promise<void> {
const monthStart = toDateKey(calendarYear, calendarMonth, 1);
const monthEnd = toDateKey(calendarYear, calendarMonth, new Date(calendarYear, calendarMonth + 1, 0).getDate());
const dataDirectory = await getDataDirectory();
if (!dataDirectory) {
calendarSignals = {};
return;
}
const results = await searchEntriesAsItems({
dataDirectory,
startDate: monthStart,
endDate: monthEnd
});
let nextSignals: Record<string, CalendarSignal> = {};
for (const item of results) {
const dateKey = item.label.trim();
const date = parseDateLabel(dateKey);
if (!date) continue;
const signal = nextSignals[dateKey] ?? { count: 0, hasTrigger: false, hasMood: false, hasOpenTodos: false };
signal.count += 1;
const content = item.initialContent ?? "";
const lower = content.toLowerCase();
signal.hasTrigger = signal.hasTrigger || lower.includes("!trigger") || lower.includes("#trigger") || lower.includes("#stress");
signal.hasMood = signal.hasMood || lower.includes("mental / emotional snapshot") || lower.includes("cognitive state");
signal.hasOpenTodos = signal.hasOpenTodos || /-\s*\[\s\]/.test(content);
nextSignals[dateKey] = signal;
}
calendarSignals = nextSignals;
}
async function refreshCalendarTimeline(): Promise<void> {
calendarBusy = true;
calendarError = "";
try {
const dataDirectory = await getDataDirectory();
if (!dataDirectory) {
calendarTimelineItems = [];
return;
}
const { startDate, endDate } = getActiveDateRange();
const items = await searchEntriesAsItems({
dataDirectory,
startDate,
endDate,
query: calendarQuery.trim() || undefined,
tags: parseCsv(calendarTags),
types: parseCsv(calendarTypes),
checked: parseCsv(calendarChecked),
unchecked: parseCsv(calendarUnchecked)
});
const sorted = [...items].sort((a, b) => {
const aDate = parseDateLabel(a.label)?.getTime() ?? 0;
const bDate = parseDateLabel(b.label)?.getTime() ?? 0;
return calendarSortMode === "asc" ? aDate - bDate : bDate - aDate;
});
calendarTimelineItems = sorted;
} catch (error) {
calendarError = String(error);
calendarTimelineItems = [];
} finally {
calendarBusy = false;
}
}
function loadSavedViews() {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(CALENDAR_SAVED_VIEWS_KEY);
if (!raw) {
calendarSavedViews = [];
return;
}
try {
const parsed = JSON.parse(raw) as SavedCalendarView[];
calendarSavedViews = Array.isArray(parsed) ? parsed : [];
} catch {
calendarSavedViews = [];
}
}
function persistSavedViews() {
if (typeof window === "undefined") return;
window.localStorage.setItem(CALENDAR_SAVED_VIEWS_KEY, JSON.stringify(calendarSavedViews));
}
function applySavedView(view: SavedCalendarView) {
calendarViewMode = view.viewMode;
calendarSortMode = view.sortMode;
calendarQuery = view.query;
calendarTags = view.tags;
calendarTypes = view.types;
calendarChecked = view.checked;
calendarUnchecked = view.unchecked;
calendarStartDate = view.startDate;
calendarEndDate = view.endDate;
}
function saveCurrentView() {
const name = saveViewName.trim();
if (!name) {
showSaveViewInput = false;
saveViewName = "";
return;
}
const view: SavedCalendarView = {
id: `view-${Date.now()}`,
name,
viewMode: calendarViewMode,
sortMode: calendarSortMode,
query: calendarQuery,
tags: calendarTags,
types: calendarTypes,
checked: calendarChecked,
unchecked: calendarUnchecked,
startDate: calendarStartDate,
endDate: calendarEndDate
};
calendarSavedViews = [view, ...calendarSavedViews];
persistSavedViews();
showSaveViewInput = false;
saveViewName = "";
}
function deleteSavedView(id: string) {
calendarSavedViews = calendarSavedViews.filter((view) => view.id !== id);
persistSavedViews();
}
const builtInViews: SavedCalendarView[] = [
{
id: "builtin-week",
name: "This Week",
viewMode: "week",
sortMode: "desc",
query: "",
tags: "",
types: "",
checked: "",
unchecked: "",
startDate: "",
endDate: ""
},
{
id: "builtin-trigger",
name: "Trigger Review",
viewMode: "month",
sortMode: "desc",
query: "",
tags: "stress, trigger",
types: "!TRIGGER",
checked: "",
unchecked: "",
startDate: "",
endDate: ""
},
{
id: "builtin-open-todos",
name: "Open Checklist",
viewMode: "month",
sortMode: "desc",
query: "",
tags: "",
types: "",
checked: "",
unchecked: "todo, follow up, check",
startDate: "",
endDate: ""
}
];
function openOrCreateDailyEntry(dateKey: string) {
const existing = $entriesStore.find((item) => item.label === dateKey && !item.id.startsWith("entries/template-draft-"));
if (existing) {
onOpenDocument(existing);
return;
}
const draft: SidePanelItem = {
id: `entries/draft-${Date.now()}`,
label: dateKey,
initialContent: `# ${dateKey}\n\n`
};
entriesStore.update((items) => [draft, ...items]);
onEditItem(draft);
}
function handleSelectedDateChange(payload: { year: number; month: number; day: number; key: string }) {
selectedCalendarDate = payload;
}
function handleDateActivate(payload: { year: number; month: number; day: number; key: string }) {
selectedCalendarDate = payload;
if (activeSection !== "calendar") return;
openOrCreateDailyEntry(payload.key);
}
function toTemplateStoreId(filePath: string): string {
return `entries/template-file/${encodeURIComponent(filePath)}`;
}
@ -101,68 +405,6 @@
}
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
function getCalendarEntries(year: number, month: number): SidePanelItem[] {
const monthKey = toMonthKey(year, month);
const shortLabel = new Date(year, month, 1).toLocaleString(undefined, {
month: "short",
year: "numeric"
});
const totalDays = daysInMonth(year, month);
const anchors = [3, 10, 17, 24].map((day) => Math.min(day, totalDays));
const entries: Array<SidePanelItem & { day: number; dateKey: string }> = [
{
id: `calendar/${monthKey}/overview`,
label: `${shortLabel} Overview`,
initialContent: `# ${shortLabel} Overview\n\nMonthly highlights and notes.`,
day: anchors[0],
dateKey: toDateKey(year, month, anchors[0])
},
{
id: `calendar/${monthKey}/goals`,
label: `${shortLabel} Goals`,
initialContent: `# ${shortLabel} Goals\n\n- Goal 1\n- Goal 2\n- Goal 3`,
day: anchors[1],
dateKey: toDateKey(year, month, anchors[1])
},
{
id: `calendar/${monthKey}/review`,
label: `${shortLabel} Review`,
initialContent: `# ${shortLabel} Review\n\nWhat went well and what to improve next month.`,
day: anchors[2],
dateKey: toDateKey(year, month, anchors[2])
},
{
id: `calendar/${monthKey}/notes`,
label: `${shortLabel} Notes`,
initialContent: `# ${shortLabel} Notes\n\nFreeform notes for this month.`,
day: anchors[3],
dateKey: toDateKey(year, month, anchors[3])
}
];
const selected = selectedCalendarDate;
const isSelectedInVisibleMonth = selected && selected.year === year && selected.month === month;
if (!isSelectedInVisibleMonth || !selected) {
return entries.sort((a, b) => a.day - b.day);
}
const selectedDay = selected.day;
return entries
.sort((a, b) => {
const distA = Math.abs(a.day - selectedDay);
const distB = Math.abs(b.day - selectedDay);
if (distA !== distB) return distA - distB;
return a.day - b.day;
})
.map(({ day, dateKey, ...item }) => item);
}
function handleAddItem() {
if (activeSection === "entries" || activeSection === "todos" || activeSection === "lists") {
if (activeSection === "entries") {
@ -186,17 +428,7 @@
day: 1,
key: toDateKey(calendarYear, calendarMonth, 1)
};
const monthLabel = new Date(selected.year, selected.month, selected.day).toLocaleString(undefined, {
month: "short"
});
const label = `${monthLabel} ${selected.day} Note`;
const item: SidePanelItem = {
id: `calendar/${selected.key}/note-${Date.now()}`,
label,
initialContent: `# ${label}\n\nDate: ${selected.key}\n\nAdd your note...`
};
customCalendarEntries = [item, ...customCalendarEntries];
onOpenDocument(item);
openOrCreateDailyEntry(selected.key);
}
}
@ -316,7 +548,6 @@
? $listsStore
: [];
$: isCalendarSection = activeSection === "calendar";
$: calendarEntries = [...customCalendarEntries, ...getCalendarEntries(calendarYear, calendarMonth)];
$: showItemActions = activeSection === "entries" || activeSection === "todos" || activeSection === "lists" || activeSection === "fragments";
$: entryItems = activeSection === "entries"
? $entriesStore.filter((item) => !item.id.startsWith("entries/template-draft-"))
@ -335,6 +566,53 @@
$: if (activeSection !== "entries") {
wasEntriesSection = false;
}
$: if (activeSection === "calendar" && !hasLoadedSavedViews) {
hasLoadedSavedViews = true;
loadSavedViews();
}
$: calendarTimelineRefreshKey = JSON.stringify({
activeSection,
calendarYear,
calendarMonth,
selectedCalendarDate,
calendarViewMode,
calendarSortMode,
calendarQuery,
calendarTags,
calendarTypes,
calendarChecked,
calendarUnchecked,
calendarStartDate,
calendarEndDate
});
$: if (activeSection === "calendar" && calendarTimelineRefreshKey !== lastCalendarTimelineKey) {
lastCalendarTimelineKey = calendarTimelineRefreshKey;
if (calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce);
}
calendarTimelineDebounce = setTimeout(() => {
void refreshCalendarTimeline();
}, 200);
}
$: calendarSignalsRefreshKey = JSON.stringify({
activeSection,
calendarYear,
calendarMonth
});
$: if (activeSection === "calendar" && calendarSignalsRefreshKey !== lastCalendarSignalsKey) {
lastCalendarSignalsKey = calendarSignalsRefreshKey;
void refreshCalendarSignals();
}
$: if (activeSection === "calendar") {
onCalendarStateChange({
items: calendarTimelineItems,
busy: calendarBusy,
error: calendarError
});
}
</script>
<section class="side-panel" aria-label="Section panel">
@ -356,26 +634,101 @@
</header>
{#if isCalendarSection}
<div class="calendar-controls">
<div class="calendar-control-row">
<label>
View
<select bind:value={calendarViewMode}>
<option value="day">Day</option>
<option value="week">Week</option>
<option value="month">Month</option>
</select>
</label>
<label>
Sort
<select bind:value={calendarSortMode}>
<option value="desc">Newest</option>
<option value="asc">Oldest</option>
</select>
</label>
</div>
<div class="calendar-control-row">
<label>
Start
<input type="date" bind:value={calendarStartDate} />
</label>
<label>
End
<input type="date" bind:value={calendarEndDate} />
</label>
</div>
<div class="calendar-control-row">
<input type="text" bind:value={calendarQuery} placeholder="Text query" />
<input type="text" bind:value={calendarTags} placeholder="Tags (comma)" />
</div>
<div class="calendar-control-row">
<input type="text" bind:value={calendarTypes} placeholder="Fragment types (comma)" />
<input type="text" bind:value={calendarChecked} placeholder="Checked todos (comma)" />
</div>
<div class="calendar-control-row">
<input type="text" bind:value={calendarUnchecked} placeholder="Unchecked todos (comma)" />
<span></span>
</div>
<div class="calendar-saved-views">
<h3>Saved Views</h3>
<div class="saved-view-actions">
{#each builtInViews as view}
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button>
{/each}
<button type="button" class="saved-view-btn" on:click={() => (showSaveViewInput = true)}>Save Current</button>
</div>
{#if showSaveViewInput}
<div class="saved-view-input">
<input
type="text"
bind:value={saveViewName}
placeholder="View name"
on:keydown={(event) => {
if (event.key === "Enter") saveCurrentView();
if (event.key === "Escape") {
showSaveViewInput = false;
saveViewName = "";
}
}}
on:blur={saveCurrentView}
/>
</div>
{/if}
{#if calendarSavedViews.length > 0}
<ul class="saved-view-list">
{#each calendarSavedViews as view}
<li>
<button type="button" class="saved-view-btn" on:click={() => applySavedView(view)}>{view.name}</button>
<button type="button" class="saved-view-delete" on:click={() => deleteSavedView(view.id)} aria-label="Delete saved view">
<span class="material-symbols-outlined">delete</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<CalendarWidget
onVisibleMonthChange={handleVisibleMonthChange}
onSelectedDateChange={handleSelectedDateChange}
onDateActivate={handleDateActivate}
signalsByDate={calendarSignals}
/>
<div class="calendar-entries">
<h3>{calendarMonthLabel} {calendarYear} Entries</h3>
<ul class="panel-list">
{#each calendarEntries as item}
<li class:is-active={item.id === activeDocumentId}>
<button
type="button"
class="item-label"
on:click={() => onOpenDocument(item)}
>
{item.label}
</button>
</li>
{/each}
</ul>
<h3>{calendarMonthLabel} {calendarYear} Timeline</h3>
<p class="section-copy">Filtered items are shown in the main panel.</p>
</div>
{:else}
<div class="panel-search">
@ -663,6 +1016,117 @@
gap: 6px;
}
.calendar-controls {
display: flex;
flex-direction: column;
gap: 8px;
border: 1px solid var(--border-soft);
border-radius: 10px;
padding: 8px;
background: var(--surface-1);
}
.calendar-control-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.calendar-control-row label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.72rem;
color: var(--text-muted);
}
.calendar-control-row input,
.calendar-control-row select {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--surface-2);
color: var(--text-primary);
padding: 6px 8px;
font-size: 0.78rem;
}
.calendar-saved-views {
display: flex;
flex-direction: column;
gap: 6px;
}
.calendar-saved-views h3 {
font-size: 0.76rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.01em;
}
.saved-view-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.saved-view-btn {
border: 1px solid var(--border-soft);
border-radius: 7px;
padding: 5px 8px;
font-size: 0.74rem;
color: var(--text-muted);
background: var(--surface-2);
cursor: pointer;
}
.saved-view-btn:hover {
color: var(--text-primary);
border-color: var(--border-strong);
background: var(--bg-hover);
}
.saved-view-input input {
width: 100%;
border: 1px solid var(--border-soft);
border-radius: 7px;
background: var(--surface-2);
color: var(--text-primary);
padding: 6px 8px;
font-size: 0.78rem;
}
.saved-view-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.saved-view-list li {
display: flex;
align-items: center;
gap: 6px;
}
.saved-view-delete {
width: 24px;
height: 24px;
display: grid;
place-items: center;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-dim);
background: transparent;
cursor: pointer;
}
.saved-view-delete:hover {
color: #e06c75;
border-color: var(--border-soft);
background: var(--bg-hover);
}
.calendar-entries h3 {
font-size: 0.78rem;
font-weight: 600;

View File

@ -26,6 +26,11 @@
section: StartupView;
};
};
type CalendarPanelState = {
items: OpenDocument[];
busy: boolean;
error: string;
};
const initialEntry = getDefaultEntry(get(entriesStore));
@ -55,6 +60,11 @@
let pendingDeleteItemId = "";
let templateRefreshToken = 0;
let linkedBackTarget: OpenDocument["linkedFrom"] | null = null;
let calendarPanelState: CalendarPanelState = {
items: [],
busy: false,
error: ""
};
function resolveStartupSection(value: string): StartupView {
switch (value) {
@ -512,6 +522,9 @@
onOpenDocument={handleOpenDocument}
onEditItem={handleEditItem}
onDeleteItem={handleDeleteItem}
onCalendarStateChange={(state) => {
calendarPanelState = state;
}}
/>
{/if}
<EditorPanel
@ -524,6 +537,9 @@
onDeleteDocument={handleDeleteDocument}
showLinkedBackButton={linkedBackTarget !== null}
onLinkedBack={handleLinkedBack}
calendarItems={calendarPanelState.items}
calendarBusy={calendarPanelState.busy}
calendarError={calendarPanelState.error}
previewOnly={!editMode}
/>
</div>