Merge branch 'master' of https://gitea.innovativedevsolutions.org/J.Schmidt92/journal
This commit is contained in:
commit
bcdf74d9e5
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user