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

@@ -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}