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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user