feat(workspace): journal with one note per day and a calendar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s

- journal_days (migration 8) maps a person's day to an ordinary note;
  GET /journal lists the days, POST /journal/{date} opens or creates one
- the journal sits on top of the notes sidebar: days below each other
  (today first, older ones load while scrolling), a calendar with month
  and year picker follows the day in view and jumps to any day
- the header shares the day in view; journal notes stay out of the folder
  tree; can be switched off per device under Darstellung

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 19:20:58 +01:00
1 parent 261579f0f2
commit 9008d7c915
19 files changed
+864 -19

No files matched your search

@@ -32,6 +32,7 @@ import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -150,6 +151,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled();
const [journalOn, setJournalOn] = useState(isJournalEnabled);
useEffect(() => {
api
.getAuthStatus()
@@ -854,6 +856,24 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Journal anzeigen</p>
<p className="text-[11px] text-faint">
Eine Notiz pro Tag, untereinander mit Kalender – ganz oben in der Notizen-Seitenleiste. Ausschalten
blendet es nur aus, die Tage bleiben als Notizen erhalten. Gilt für dieses Gerät.
</p>
</div>
<ToggleSwitch
checked={journalOn}
onChange={(v) => {
setJournalEnabled(v);
setJournalOn(v);
}}
title="Journal anzeigen"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => {
const isSelected = activeTheme === t.id;
@@ -61,6 +61,8 @@ export interface DocumentEditorProps {
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
onDocCreated?: (doc: Document) => void;
onDocRemoved?: (docId: string) => void;
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
variant?: 'page' | 'journal';
}
import { EmojiPickerModal } from './EmojiPickerModal';
@@ -211,7 +213,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onRequestMerge,
onDocCreated,
onDocRemoved,
variant = 'page',
}) => {
const inJournal = variant === 'journal';
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
@@ -245,7 +249,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
useEffect(() => {
const root = scrollRef.current;
const el = titleInputRef.current;
if (!root || !el || typeof IntersectionObserver === 'undefined') {
if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') {
setTitleScrolledAway(false);
return;
}
@@ -256,7 +260,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
);
observer.observe(el);
return () => observer.disconnect();
}, [zoomedBlockId, doc?.id]);
}, [zoomedBlockId, doc?.id, inJournal]);
useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
@@ -782,8 +786,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}
return (
<div ref={scrollRef} className="flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto" data-sync-channel={engineState?.channel}>
{/* Top Document Action Bar */}
<div
ref={scrollRef}
className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
data-sync-channel={engineState?.channel}
>
{/* Top Document Action Bar (the journal has one for all days) */}
{!inJournal && (
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
<div className="flex flex-1 items-center gap-2 min-w-0">
{onBackMobile && (
@@ -1017,6 +1026,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
</div>
</div>
)}
{/* In the trash: read-only with a way back */}
{inTrash && (
@@ -1047,7 +1057,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Main Content Area */}
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
<div className="w-full pl-2 pr-1 sm:px-8 py-5 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch]">
<div
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
inJournal ? 'py-3' : 'py-5'
}`}
>
{zoomedBlock ? (
/* =========================================================================
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
@@ -1127,6 +1141,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
</div>
) : (
<OutlinerTree
compact={inJournal}
docId={docId}
blocks={childBlocks}
canEdit={canEdit}
@@ -1162,7 +1177,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<div className="relative">
<button
type="button"
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
onClick={() => canEdit && !inJournal && setShowEmojiPicker((prev) => !prev)}
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
title="Icon ändern"
>
@@ -1187,7 +1202,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}}
rows={1}
value={title}
readOnly={!canEdit}
readOnly={!canEdit || inJournal}
autoFocus={autoFocusTitle}
onChange={(e) => {
handleTitleChange(e);
@@ -1214,6 +1229,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Logseq Outliner Tree */}
<OutlinerTree
compact={inJournal}
ref={outlinerTreeRef}
docId={docId}
blocks={blocks}
@@ -1239,7 +1255,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onRedo={handleRedo}
/>
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
{onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</>
)}
</div>
@@ -0,0 +1,123 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { MONTHS, monthGrid, parseISODate, todayISO } from '../../lib/journal';
interface JournalCalendarProps {
/** Day currently in view; the calendar follows its month */
activeDate: string;
/** Days with written entries (get a dot) */
entryDates: Set<string>;
onSelect: (date: string) => void;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, entryDates, onSelect }) => {
const active = parseISODate(activeDate);
const [view, setView] = useState({ year: active.getFullYear(), month: active.getMonth() });
useEffect(() => {
const d = parseISODate(activeDate);
setView({ year: d.getFullYear(), month: d.getMonth() });
}, [activeDate]);
const today = todayISO();
const weeks = useMemo(() => monthGrid(view.year, view.month), [view]);
const years = useMemo(() => {
const now = new Date().getFullYear();
let first = now - 5;
entryDates.forEach((d) => {
first = Math.min(first, Number(d.slice(0, 4)));
});
const list: number[] = [];
for (let y = now + 1; y >= first; y--) list.push(y);
return list;
}, [entryDates]);
const shiftMonth = (delta: number) =>
setView((v) => {
const d = new Date(v.year, v.month + delta, 1);
return { year: d.getFullYear(), month: d.getMonth() };
});
return (
<div className="select-none text-xs" aria-label="Journal-Kalender">
<div className="flex items-center gap-1 mb-1.5">
<button
type="button"
onClick={() => shiftMonth(-1)}
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
title="Vorheriger Monat"
aria-label="Vorheriger Monat"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<select
value={view.month}
onChange={(e) => setView((v) => ({ ...v, month: Number(e.target.value) }))}
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
aria-label="Monat"
>
{MONTHS.map((m, i) => (
<option key={m} value={i}>
{m}
</option>
))}
</select>
<select
value={view.year}
onChange={(e) => setView((v) => ({ ...v, year: Number(e.target.value) }))}
className="w-[4.5rem] h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
aria-label="Jahr"
>
{years.map((y) => (
<option key={y} value={y}>
{y}
</option>
))}
</select>
<button
type="button"
onClick={() => shiftMonth(1)}
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
title="Nächster Monat"
aria-label="Nächster Monat"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] text-faint font-semibold mb-0.5">
{WEEKDAYS.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="grid grid-cols-7 gap-0.5">
{weeks.flat().map((date, i) =>
date ? (
<button
key={date}
type="button"
onClick={() => onSelect(date)}
aria-current={date === activeDate ? 'date' : undefined}
title={parseISODate(date).toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
className={`relative h-9 sm:h-7 rounded-btn text-xs tabular-nums transition-colors cursor-pointer ${
date === activeDate
? 'bg-accent-dim text-accent font-bold'
: date === today
? 'text-accent font-semibold ring-1 ring-inset ring-accent-border hover:bg-panel-elevated'
: 'text-main hover:bg-panel-elevated'
}`}
>
{Number(date.slice(8))}
{entryDates.has(date) && (
<span className="absolute left-1/2 -translate-x-1/2 bottom-0.5 w-1 h-1 rounded-full bg-accent" />
)}
</button>
) : (
<span key={`empty-${i}`} />
)
)}
</div>
</div>
);
};
@@ -0,0 +1,246 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, CalendarDays, ChevronDown, Users } from 'lucide-react';
import { Document, JournalDay, User } from '../../lib/types';
import { shortDateLabel, todayISO } from '../../lib/journal';
import { DocumentEditor } from './DocumentEditor';
import { JournalCalendar } from './JournalCalendar';
const PAGE = 7;
interface JournalViewProps {
days: JournalDay[];
/** Days jumped to over the calendar (shown even while still empty) */
extraDates: string[];
activeDate: string;
onActiveDateChange: (date: string) => void;
/** Set by the calendar; the view scrolls there (nonce: the same day again) */
jumpRequest: { date: string; nonce: number } | null;
/** Opens (creates on first use) the note of a day */
onOpenDay: (date: string) => Promise<Document | null>;
onJump: (date: string) => void;
getDoc: (id: string) => Document | undefined;
currentUser?: User | null;
availableFolders: string[];
onOpenShareModal: (doc: Document) => void;
onDocMetadataChange: (updated: Document) => void;
onDeleteDoc: (docId: string) => void;
onSelectTag: (tag: string) => void;
onWikiLinkClick?: (pageTitle: string) => void;
onOpenDocBlock?: (docId: string, blockId?: string) => void;
onOpenTask?: (taskId: string) => void;
onBackMobile: () => void;
/** A day was saved (its calendar dot may change) */
onDaySaved: () => void;
}
// The journal: one note per day below each other, today on top, older days load while scrolling.
export const JournalView: React.FC<JournalViewProps> = ({
days,
extraDates,
activeDate,
onActiveDateChange,
jumpRequest,
onOpenDay,
onJump,
getDoc,
currentUser,
availableFolders,
onOpenShareModal,
onDocMetadataChange,
onDeleteDoc,
onSelectTag,
onWikiLinkClick,
onOpenDocBlock,
onOpenTask,
onBackMobile,
onDaySaved,
}) => {
const today = todayISO();
const docIdByDate = useMemo(() => new Map(days.map((d) => [d.date, d.docId])), [days]);
const entryDates = useMemo(() => new Set(days.filter((d) => !d.empty).map((d) => d.date)), [days]);
// Today, every day with text and the days jumped to – newest first
const dates = useMemo(() => {
const set = new Set<string>([today, ...extraDates]);
days.forEach((d) => {
if (!d.empty) set.add(d.date);
});
return Array.from(set).sort((a, b) => b.localeCompare(a));
}, [days, extraDates, today]);
const [limit, setLimit] = useState(PAGE);
const shown = dates.slice(0, limit);
const [showCalendar, setShowCalendar] = useState(false);
// Days on screen without a note yet (today, a day jumped to) get one
const opening = useRef(new Set<string>());
useEffect(() => {
shown.forEach((date) => {
if (!docIdByDate.has(date) && !opening.current.has(date)) {
opening.current.add(date);
void onOpenDay(date).finally(() => opening.current.delete(date));
}
});
}, [shown, docIdByDate, onOpenDay]);
const scrollRef = useRef<HTMLDivElement | null>(null);
const sectionRefs = useRef(new Map<string, HTMLElement>());
// Jump: make sure the day is rendered, then scroll to it once its note is there
const pendingJump = useRef<string | null>(null);
const handledJump = useRef(0);
useEffect(() => {
if (!jumpRequest || handledJump.current === jumpRequest.nonce) return;
const idx = dates.indexOf(jumpRequest.date);
if (idx === -1) return; // the parent adds the day first
handledJump.current = jumpRequest.nonce;
if (idx >= limit) setLimit(idx + PAGE);
pendingJump.current = jumpRequest.date;
}, [jumpRequest, dates, limit]);
useEffect(() => {
const date = pendingJump.current;
if (!date) return;
const el = sectionRefs.current.get(date);
if (el && docIdByDate.has(date)) {
pendingJump.current = null;
el.scrollIntoView({ block: 'start' });
}
});
// The day at the top of the scroll area is the active one (calendar, share button)
const onScroll = useCallback(() => {
const root = scrollRef.current;
if (!root) return;
const top = root.getBoundingClientRect().top + 48;
let current = shown[0];
for (const date of shown) {
const el = sectionRefs.current.get(date);
if (!el) continue;
if (el.getBoundingClientRect().top <= top) current = date;
else break;
}
if (current && current !== activeDate) onActiveDateChange(current);
}, [shown, activeDate, onActiveDateChange]);
// Load older days when the end comes into view
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinelRef.current;
const root = scrollRef.current;
if (!el || !root || typeof IntersectionObserver === 'undefined' || limit >= dates.length) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setLimit((l) => l + PAGE);
},
{ root, rootMargin: '400px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [limit, dates.length]);
const activeDocId = docIdByDate.get(activeDate);
const activeDoc = activeDocId ? getDoc(activeDocId) : undefined;
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
return (
<div className="flex-1 flex flex-col min-h-0 bg-editor">
{/* One header for all days: the day in view and its sharing */}
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-icon shrink-0 h-11 w-11"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setShowCalendar((v) => !v)}
className="lg:pointer-events-none flex-1 min-w-0 flex items-center gap-1.5 text-xs text-main font-medium text-left cursor-pointer"
aria-expanded={showCalendar}
title="Kalender"
>
<CalendarDays className="w-3.5 h-3.5 text-accent shrink-0" />
<span className="truncate">Journal · {shortDateLabel(activeDate)}</span>
<ChevronDown className={`lg:hidden w-3.5 h-3.5 text-muted shrink-0 transition-transform ${showCalendar ? 'rotate-180' : ''}`} />
</button>
{activeDate !== today && (
<button
type="button"
onClick={() => onJump(today)}
className="btn-action shrink-0 h-11 sm:h-7 px-2.5 text-xs"
>
Heute
</button>
)}
<button
type="button"
disabled={!activeDoc || !activeDoc.isOwner}
onClick={() => activeDoc && onOpenShareModal(activeDoc)}
className="btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-auto sm:px-2 gap-1 disabled:opacity-40"
title={activeDoc ? `${activeDoc.title} teilen` : 'Teilen'}
>
<Users className="w-3.5 h-3.5" />
<span className="hidden sm:inline text-xs">{sharedCount > 0 ? `Geteilt (${sharedCount})` : 'Teilen'}</span>
</button>
</div>
{showCalendar && (
<div className="lg:hidden shrink-0 px-4 py-3 border-b border-border bg-sidebar">
<JournalCalendar
activeDate={activeDate}
entryDates={entryDates}
onSelect={(date) => {
setShowCalendar(false);
onJump(date);
}}
/>
</div>
)}
<div ref={scrollRef} onScroll={onScroll} className="flex-1 min-h-0 overflow-y-auto">
{shown.map((date) => {
const docId = docIdByDate.get(date);
return (
<section
key={date}
data-journal-date={date}
ref={(el) => {
if (el) sectionRefs.current.set(date, el);
else sectionRefs.current.delete(date);
}}
className="border-b border-border last:border-b-0 min-h-[8rem]"
>
{docId ? (
<DocumentEditor
key={docId}
variant="journal"
docId={docId}
currentUser={currentUser}
initialDoc={getDoc(docId) || null}
availableFolders={availableFolders}
onDocMetadataChange={onDocMetadataChange}
onDeleteDoc={onDeleteDoc}
onOpenShareModal={onOpenShareModal}
onSelectTag={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
onOpenDocBlock={onOpenDocBlock}
onOpenTask={onOpenTask}
onSaveStatusChange={(status) => {
if (status === 'saved') onDaySaved();
}}
/>
) : (
<div className="px-4 sm:px-8 py-6 text-xs text-muted">{shortDateLabel(date)} wird geöffnet…</div>
)}
</section>
);
})}
{limit < dates.length && <div ref={sentinelRef} className="h-12" />}
{/* Room below the oldest day, so every day can be scrolled to the top */}
<div className="h-[60vh]" aria-hidden />
</div>
</div>
);
};
@@ -9,6 +9,8 @@ export interface OutlinerTreeHandle {
export interface OutlinerTreeProps {
docId: string;
/** Smaller click area below the last bullet (journal days) */
compact?: boolean;
blocks: DocumentBlock[];
canEdit: boolean;
levelOffset?: number;
@@ -34,6 +36,7 @@ export interface OutlinerTreeProps {
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
docId,
compact = false,
blocks,
canEdit,
levelOffset = 0,
@@ -810,7 +813,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
{canEdit && (
<div
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
}`}
onClick={(e) => {
@@ -23,6 +23,7 @@ import {
Clock,
AlertTriangle,
Loader2,
CalendarDays,
} from 'lucide-react';
import {
Document,
@@ -36,6 +37,7 @@ import {
TrashedDocument,
UnifiedShare,
User,
JournalDay,
} from '../../lib/types';
import {
api,
@@ -49,6 +51,9 @@ import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView';
import { JournalView } from './JournalView';
import { JournalCalendar } from './JournalCalendar';
import { isJournalEnabled, todayISO } from '../../lib/journal';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
@@ -63,7 +68,7 @@ interface WorkspaceViewProps {
onOpenSearch?: () => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal';
interface FolderTreeNode {
name: string;
@@ -327,6 +332,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
// Journal: one note per day, listed separately from the folder tree (can be switched off per device)
const [journalEnabled] = useState(isJournalEnabled);
const [journalDays, setJournalDays] = useState<JournalDay[]>([]);
const [journalExtra, setJournalExtra] = useState<string[]>([]);
const [journalActive, setJournalActive] = useState(todayISO);
const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null);
const reloadJournal = useCallback(() => {
if (!journalEnabled) return;
api.getJournal().then(setJournalDays).catch(() => {});
}, [journalEnabled]);
useEffect(() => {
reloadJournal();
}, [reloadJournal]);
// After typing, refresh which days have text (calendar dots) – at most every few seconds
const journalReloadTimer = useRef<number | null>(null);
const reloadJournalSoon = useCallback(() => {
if (journalReloadTimer.current !== null) return;
journalReloadTimer.current = window.setTimeout(() => {
journalReloadTimer.current = null;
reloadJournal();
}, 3000);
}, [reloadJournal]);
useEffect(() => () => {
if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current);
}, []);
const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
@@ -560,6 +592,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setMobileShowDetail(true);
}, []);
const openJournalDay = useCallback(async (date: string): Promise<Document | null> => {
try {
const doc = await api.openJournalDay(date);
setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev]));
setJournalDays((prev) =>
[
...prev.filter((d) => d.date !== date),
{ date, docId: doc.id, empty: !(doc.content || '').trim() },
].sort((a, b) => b.date.localeCompare(a.date))
);
return doc;
} catch {
return null;
}
}, []);
const jumpToJournalDay = useCallback((date: string) => {
setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date]));
setJournalActive(date);
setJournalJump({ date, nonce: Date.now() });
}, []);
const openJournal = useCallback(() => {
reloadJournal();
setSubView('journal');
setMobileShowDetail(true);
}, [reloadJournal]);
const openTrash = useCallback(() => {
reloadTrash();
setSubView('trash');
@@ -939,6 +999,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
// Own journal notes appear under "Journal", not in the folder tree
const treeDocs = useMemo(
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
[docs, journalDocIds]
);
const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders);
for (const d of docs) {
@@ -977,7 +1043,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p
);
const folderDocs = docs.filter((d) => d.folderPath === p);
const folderDocs = treeDocs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = {
name: leafName,
@@ -1002,16 +1068,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
return rootNodes;
}, [allFolderPaths, docs, sharedFolders]);
}, [allFolderPaths, treeDocs, sharedFolders]);
const pinnedDocs = useMemo(
() => docs.filter((d) => d.isPinned),
[docs]
() => treeDocs.filter((d) => d.isPinned),
[treeDocs]
);
const rootDocs = useMemo(
() => docs.filter((d) => !d.folderPath && !d.isPinned),
[docs]
() => treeDocs.filter((d) => !d.folderPath && !d.isPinned),
[treeDocs]
);
// Global To-Dos rollup collections
@@ -1451,7 +1517,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* LEFT MASTER SIDEBAR */}
<aside
className={`${
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
@@ -1503,6 +1569,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Journal on top; while open, its calendar stays below it */}
{journalEnabled && (
<div className="border-b border-border pb-1.5 -mb-1.5">
<button
type="button"
onClick={openJournal}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'journal'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<CalendarDays className="w-3.5 h-3.5 text-accent" />
<span className="flex-1 text-left">Journal</span>
{subView === 'journal' ? (
<ChevronDown className="w-3.5 h-3.5 text-muted" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-muted" />
)}
</button>
{subView === 'journal' && (
<div className="hidden lg:block px-1 pt-2 pb-1">
<JournalCalendar
activeDate={journalActive}
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
onSelect={jumpToJournalDay}
/>
</div>
)}
</div>
)}
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
) : (
@@ -1680,7 +1777,29 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`}
>
{subView === 'trash' ? (
{subView === 'journal' && journalEnabled ? (
<JournalView
days={journalDays}
extraDates={journalExtra}
activeDate={journalActive}
onActiveDateChange={setJournalActive}
jumpRequest={journalJump}
onOpenDay={openJournalDay}
onJump={jumpToJournalDay}
getDoc={(id) => docs.find((d) => d.id === id)}
currentUser={currentUser}
availableFolders={allFolderPaths}
onOpenShareModal={openDocShareModal}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onOpenTask={onOpenTask}
onBackMobile={() => setMobileShowDetail(false)}
onDaySaved={reloadJournalSoon}
/>
) : subView === 'trash' ? (
<TrashView
items={trash}
onOpen={handleOpenTrashedDoc}
@@ -0,0 +1,16 @@
import { describe, it, expect } from 'vitest';
import { monthGrid, toISODate, parseISODate } from '../journal';
describe('journal dates', () => {
it('builds a Monday-first month grid', () => {
// October 2026 starts on a Thursday
const weeks = monthGrid(2026, 9);
expect(weeks[0]).toEqual([null, null, null, '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']);
expect(weeks.flat().filter(Boolean)).toHaveLength(31);
expect(weeks.every((w) => w.length === 7)).toBe(true);
});
it('round-trips local dates', () => {
expect(toISODate(parseISODate('2026-03-01'))).toBe('2026-03-01');
});
});
+12
View File
@@ -4,6 +4,7 @@ import {
DashboardStats,
Document,
TrashedDocument,
JournalDay,
BackupFile,
DataSection,
ApiKeyScope,
@@ -723,6 +724,17 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
/** Days of the own journal (newest first) with their notes. */
async getJournal(): Promise<JournalDay[]> {
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
return res.days || [];
},
/** The note of a journal day (YYYY-MM-DD); created on first use. */
async openJournalDay(date: string): Promise<Document> {
return request<Document>(`/api/v1/journal/${date}`, { method: 'POST' });
},
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
+62
View File
@@ -0,0 +1,62 @@
// Journal helpers: dates are local calendar days as YYYY-MM-DD (the server stores them as given).
export const JOURNAL_ENABLED_KEY = 'mindsift_journal_enabled';
export function isJournalEnabled(): boolean {
try {
return localStorage.getItem(JOURNAL_ENABLED_KEY) !== 'false';
} catch {
return true;
}
}
export function setJournalEnabled(on: boolean) {
try {
localStorage.setItem(JOURNAL_ENABLED_KEY, on ? 'true' : 'false');
} catch {
// storage blocked: the setting simply does not stick
}
}
export function toISODate(d: Date): string {
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${m}-${day}`;
}
export function todayISO(): string {
return toISODate(new Date());
}
export function parseISODate(date: string): Date {
const [y, m, d] = date.split('-').map(Number);
return new Date(y, (m || 1) - 1, d || 1);
}
export const MONTHS = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
/** "Fr, 30. Okt. 2026" for compact labels */
export function shortDateLabel(date: string): string {
return parseISODate(date).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
/** Weeks of a month for a Monday-first grid; days outside the month are null. */
export function monthGrid(year: number, month: number): (string | null)[][] {
const first = new Date(year, month, 1);
const offset = (first.getDay() + 6) % 7; // Monday = 0
const daysInMonth = new Date(year, month + 1, 0).getDate();
const cells: (string | null)[] = Array(offset).fill(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(toISODate(new Date(year, month, d)));
while (cells.length % 7 !== 0) cells.push(null);
const weeks: (string | null)[][] = [];
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
return weeks;
}
+7
View File
@@ -322,6 +322,13 @@ export interface Backlink {
}
/** A note in the trash (GET /documents/trash). */
/** A day of the own journal; `empty` while its note has no text yet. */
export interface JournalDay {
date: string; // YYYY-MM-DD
docId: string;
empty: boolean;
}
export interface TrashedDocument {
id: string;
title: string;