feat(workspace): journal with one note per day and a calendar
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m6s
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:
1 parent
261579f0f2
commit
9008d7c915
19 files changed
+864
-19
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user