Refine calendar filters and manual refresh behavior

This commit is contained in:
Jacob Schmidt 2026-02-27 22:12:10 -06:00
parent 6c1c65d5c7
commit 5b244ff766
2 changed files with 191 additions and 155 deletions

View File

@ -3,7 +3,6 @@
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();
@ -33,11 +32,6 @@
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);
}
@ -154,16 +148,6 @@
on:click={() => selectCell(cell)}
>
<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>
@ -253,49 +237,6 @@
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 {
color: var(--text-primary);
background: var(--bg-hover);

View File

@ -1,9 +1,12 @@
<script lang="ts">
import { listEntryTemplates, type EntryTemplateItemDto } from "$lib/backend/templates";
import { listFragments, type FragmentDto } from "$lib/backend/fragments";
import { listLists, type ListDocumentDto } from "$lib/backend/lists";
import { listTodoLists, type TodoListDto } from "$lib/backend/todos";
import { sendCommand } from "$lib/backend/client";
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
import { entriesBusyStore, entriesStore, searchEntriesAsItems } from "$lib/stores/entries";
import { createFragmentDraft, fragmentsStore } from "$lib/stores/fragments";
import { createFragmentDraft, fragmentsStore, serializeFragment } from "$lib/stores/fragments";
import { createListDraft, createListFromLabel, listsStore } from "$lib/stores/lists";
import { createTodoListDraft, createTodoListFromLabel, serializeTodoList, todoListsStore, todosStore } from "$lib/stores/todos";
@ -24,15 +27,10 @@
id: string;
label: string;
initialContent: string;
sortDate?: 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;
@ -41,8 +39,6 @@
query: string;
tags: string;
types: string;
checked: string;
unchecked: string;
startDate: string;
endDate: string;
};
@ -73,12 +69,9 @@
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[] = [];
@ -86,8 +79,9 @@
let saveViewName = "";
let hasLoadedSavedViews = false;
let lastCalendarTimelineKey = "";
let lastCalendarSignalsKey = "";
let calendarTimelineDebounce: ReturnType<typeof setTimeout> | null = null;
let lastActiveSection = "";
let calendarLastRefreshedAt = "";
const CALENDAR_SAVED_VIEWS_KEY = "journal.calendar.savedViews";
let selectedCalendarDate: { year: number; month: number; day: number; key: string } | null = {
@ -115,15 +109,36 @@
}
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`);
const token = value.trim().split(/\s*[|·]\s*/)[0].trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(token)) return null;
const date = new Date(`${token}T00:00:00`);
return Number.isNaN(date.getTime()) ? null : date;
}
function parseIsoDate(value: string): Date | null {
if (!value) return null;
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function toIsoDate(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
function isWithinRange(date: Date, startDate: string, endDate: string): boolean {
const start = parseDateLabel(startDate);
const end = parseDateLabel(endDate);
if (!start || !end) return true;
const day = toIsoDate(date);
return day >= toIsoDate(start) && day <= toIsoDate(end);
}
function matchesTextQuery(content: string, query: string): boolean {
const trimmed = query.trim();
if (!trimmed) return true;
return content.toLowerCase().includes(trimmed.toLowerCase());
}
function getActiveDateRange(): { startDate: string; endDate: string } {
if (calendarStartDate && calendarEndDate) {
return { startDate: calendarStartDate, endDate: calendarEndDate };
@ -162,47 +177,60 @@
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 toFragmentTimelineItem(fragment: FragmentDto): SidePanelItem {
const split = fragment.description.split(/\n{2,}/);
const title = (split[0] ?? "").trim() || "Untitled Fragment";
const body = split.slice(1).join("\n\n").trim() || "Add details for this fragment.";
const date = parseIsoDate(fragment.time) ?? new Date();
const dateKey = toIsoDate(date);
return {
id: `fragments/${fragment.id}`,
label: `${dateKey} | Fragment | ${title}`,
initialContent: serializeFragment({
title,
type: fragment.type,
tags: fragment.tags ?? [],
body
}),
sortDate: date.toISOString()
};
}
function upsertSignal(dateKey: string, next: CalendarSignal) {
calendarSignals = { ...calendarSignals, [dateKey]: next };
function toListTimelineItem(list: ListDocumentDto): SidePanelItem {
const created = parseIsoDate(list.createdAt) ?? parseIsoDate(list.updatedAt) ?? new Date();
const dateKey = toIsoDate(created);
return {
id: `lists/${list.id}`,
label: `${dateKey} | List | ${list.label}`,
initialContent: list.content || `# ${list.label}\n\n`,
sortDate: created.toISOString()
};
}
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;
}
function toTodoTimelineItem(list: TodoListDto): SidePanelItem {
const created = parseIsoDate(list.createdAt) ?? new Date();
const dateKey = toIsoDate(created);
const items = list.items.map((item, index) => ({
id: Date.now() + index,
text: item.text,
done: item.done
}));
return {
id: `todos/${list.id}`,
label: `${dateKey} | To-Do | ${list.label}`,
initialContent: serializeTodoList(list.label, items),
sortDate: created.toISOString()
};
}
const results = await searchEntriesAsItems({
dataDirectory,
startDate: monthStart,
endDate: monthEnd
});
function splitFilterTokens(input: string): string[] {
return parseCsv(input).map((token) => token.toLowerCase());
}
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;
function matchesAnyToken(text: string, tokens: string[]): boolean {
if (tokens.length === 0) return true;
const lower = text.toLowerCase();
return tokens.some((token) => lower.includes(token));
}
async function refreshCalendarTimeline(): Promise<void> {
@ -216,23 +244,88 @@
}
const { startDate, endDate } = getActiveDateRange();
const items = await searchEntriesAsItems({
const entryItems = await searchEntriesAsItems({
dataDirectory,
startDate,
endDate,
query: calendarQuery.trim() || undefined,
tags: parseCsv(calendarTags),
types: parseCsv(calendarTypes),
checked: parseCsv(calendarChecked),
unchecked: parseCsv(calendarUnchecked)
types: parseCsv(calendarTypes)
});
const sorted = [...items].sort((a, b) => {
const aDate = parseDateLabel(a.label)?.getTime() ?? 0;
const bDate = parseDateLabel(b.label)?.getTime() ?? 0;
const [fragmentDtos, listDtos, todoDtos] = await Promise.all([
listFragments(),
listLists(),
listTodoLists()
]);
const query = calendarQuery.trim();
const tagTokens = splitFilterTokens(calendarTags);
const typeTokens = splitFilterTokens(calendarTypes);
const hasTypeFilter = typeTokens.length > 0;
const fragmentItems = fragmentDtos
.map(toFragmentTimelineItem)
.filter((item) => {
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(item.initialContent, query)) return false;
if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false;
if (hasTypeFilter && !matchesAnyToken(`${item.label}\n${item.initialContent}`, typeTokens)) return false;
return true;
});
const listItems = listDtos
.map(toListTimelineItem)
.filter((item) => {
if (hasTypeFilter) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false;
return true;
});
const todoItems = todoDtos
.map(toTodoTimelineItem)
.filter((item) => {
if (hasTypeFilter) return false;
const date = item.sortDate ? parseIsoDate(item.sortDate) : null;
if (!date || !isWithinRange(date, startDate, endDate)) return false;
if (!matchesTextQuery(item.initialContent, query) && !matchesTextQuery(item.label, query)) return false;
if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false;
return true;
});
const entriesWithKind: SidePanelItem[] = entryItems
.map((item) => {
const date = parseDateLabel(item.label);
const dateKey = date ? toIsoDate(date) : item.label;
return {
id: item.id,
label: `${dateKey} | Entry | ${item.label}`,
initialContent: item.initialContent,
sortDate: date ? date.toISOString() : undefined
};
})
.filter((item) => {
if (hasTypeFilter) return false;
if (!matchesAnyToken(`${item.label}\n${item.initialContent}`, tagTokens)) return false;
return true;
});
const merged = [...entriesWithKind, ...fragmentItems, ...listItems, ...todoItems];
const sorted = merged.sort((a, b) => {
const aDate = a.sortDate ? parseIsoDate(a.sortDate)?.getTime() ?? 0 : parseDateLabel(a.label)?.getTime() ?? 0;
const bDate = b.sortDate ? parseIsoDate(b.sortDate)?.getTime() ?? 0 : parseDateLabel(b.label)?.getTime() ?? 0;
return calendarSortMode === "asc" ? aDate - bDate : bDate - aDate;
});
calendarTimelineItems = sorted;
calendarLastRefreshedAt = new Date().toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
} catch (error) {
calendarError = String(error);
calendarTimelineItems = [];
@ -241,6 +334,16 @@
}
}
async function forceRefreshCalendar(options?: { allowWhileBusy?: boolean }): Promise<void> {
if (activeSection !== "calendar") return;
if (calendarBusy && !options?.allowWhileBusy) return;
if (calendarTimelineDebounce) {
clearTimeout(calendarTimelineDebounce);
calendarTimelineDebounce = null;
}
await refreshCalendarTimeline();
}
function loadSavedViews() {
if (typeof window === "undefined") return;
const raw = window.localStorage.getItem(CALENDAR_SAVED_VIEWS_KEY);
@ -268,8 +371,6 @@
calendarQuery = view.query;
calendarTags = view.tags;
calendarTypes = view.types;
calendarChecked = view.checked;
calendarUnchecked = view.unchecked;
calendarStartDate = view.startDate;
calendarEndDate = view.endDate;
}
@ -290,8 +391,6 @@
query: calendarQuery,
tags: calendarTags,
types: calendarTypes,
checked: calendarChecked,
unchecked: calendarUnchecked,
startDate: calendarStartDate,
endDate: calendarEndDate
};
@ -315,8 +414,6 @@
query: "",
tags: "",
types: "",
checked: "",
unchecked: "",
startDate: "",
endDate: ""
},
@ -328,24 +425,10 @@
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) {
@ -432,6 +515,10 @@
}
}
function handleRefreshClick() {
void forceRefreshCalendar();
}
function handleAddTemplate() {
if (activeSection !== "entries") return;
createTemplateMode = true;
@ -582,10 +669,19 @@
calendarQuery,
calendarTags,
calendarTypes,
calendarChecked,
calendarUnchecked,
calendarStartDate,
calendarEndDate
calendarEndDate,
entriesSig: $entriesStore.map((item) => `${item.id}:${item.label}`).join("|"),
fragmentsSig: $fragmentsStore.map((item) => `${item.id}:${item.label}`).join("|"),
listsSig: $listsStore.map((item) => `${item.id}:${item.label}`).join("|"),
todosSig: $todoListsStore
.map((item) => {
const todos = ($todosStore[item.id] ?? [])
.map((todo) => `${todo.text}:${todo.done ? "1" : "0"}`)
.join("~");
return `${item.id}:${item.label}:${todos}`;
})
.join("|")
});
$: if (activeSection === "calendar" && calendarTimelineRefreshKey !== lastCalendarTimelineKey) {
lastCalendarTimelineKey = calendarTimelineRefreshKey;
@ -597,15 +693,14 @@
}, 200);
}
$: calendarSignalsRefreshKey = JSON.stringify({
activeSection,
calendarYear,
calendarMonth
});
$: if (activeSection === "calendar" && calendarSignalsRefreshKey !== lastCalendarSignalsKey) {
lastCalendarSignalsKey = calendarSignalsRefreshKey;
void refreshCalendarSignals();
$: if (activeSection === "calendar" && lastActiveSection !== "calendar") {
void forceRefreshCalendar({ allowWhileBusy: true });
setTimeout(() => {
void forceRefreshCalendar({ allowWhileBusy: true });
}, 500);
}
$: lastActiveSection = activeSection;
$: if (activeSection === "calendar") {
onCalendarStateChange({
items: calendarTimelineItems,
@ -619,6 +714,11 @@
<header class="panel-header">
<h2>{panelTitle}</h2>
<div class="panel-header-actions">
{#if activeSection === "calendar"}
<button type="button" class="panel-action" aria-label="Refresh calendar" title="Refresh calendar" on:click={handleRefreshClick}>
<span class="material-symbols-outlined">refresh</span>
</button>
{/if}
{#if activeSection === "entries"}
<button type="button" class="panel-action" aria-label="Add template" title="Add template" on:click={handleAddTemplate}>
<span class="material-symbols-outlined">palette</span>
@ -671,11 +771,6 @@
<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>
@ -723,12 +818,11 @@
onVisibleMonthChange={handleVisibleMonthChange}
onSelectedDateChange={handleSelectedDateChange}
onDateActivate={handleDateActivate}
signalsByDate={calendarSignals}
/>
<div class="calendar-entries">
<h3>{calendarMonthLabel} {calendarYear} Timeline</h3>
<p class="section-copy">Filtered items are shown in the main panel.</p>
<p class="section-copy">Last refreshed: {calendarLastRefreshedAt || "Not yet"}</p>
</div>
{:else}
<div class="panel-search">
@ -1181,3 +1275,4 @@
}
}
</style>