From 9008d7c915aab12efac9c9d48b5d2fc8a04b56c9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 19:20:58 +0100 Subject: [PATCH] feat(workspace): journal with one note per day and a calendar - 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 --- CHANGELOG.md | 1 + Doku/api/rest.md | 2 + Doku/database.md | 3 +- README.md | 1 + .../features/settings/UserSettingsView.tsx | 20 ++ .../src/features/workspace/DocumentEditor.tsx | 32 ++- .../features/workspace/JournalCalendar.tsx | 123 +++++++++ .../src/features/workspace/JournalView.tsx | 246 ++++++++++++++++++ .../src/features/workspace/OutlinerTree.tsx | 5 +- .../src/features/workspace/WorkspaceView.tsx | 137 +++++++++- frontend/src/lib/__tests__/journal.test.ts | 16 ++ frontend/src/lib/api.ts | 12 + frontend/src/lib/journal.ts | 62 +++++ frontend/src/lib/types.ts | 7 + internal/api/journal.go | 70 +++++ internal/api/journal_test.go | 78 ++++++ internal/api/server.go | 2 + internal/storage/journal.go | 65 +++++ internal/storage/migrations.go | 1 + 19 files changed, 864 insertions(+), 19 deletions(-) create mode 100644 frontend/src/features/workspace/JournalCalendar.tsx create mode 100644 frontend/src/features/workspace/JournalView.tsx create mode 100644 frontend/src/lib/__tests__/journal.test.ts create mode 100644 frontend/src/lib/journal.ts create mode 100644 internal/api/journal.go create mode 100644 internal/api/journal_test.go create mode 100644 internal/storage/journal.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 796fee7..0d8b9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] ### Added +- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`. - Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse). ### Changed diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 2e839f8..074558a 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -125,6 +125,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein | GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) | | DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen | | POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) | +| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) | | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | diff --git a/Doku/database.md b/Doku/database.md index 78dd183..9a98c4f 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -14,6 +14,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j | `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` | | `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` | | `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` | +| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` | @@ -84,7 +85,7 @@ teams 1─n team_members n─1 users - `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. -- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 7: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank. +- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 8: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank. ## Sicherung diff --git a/README.md b/README.md index 5d10d77..d316fbc 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste. * **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren. +* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar. * Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`. * **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription). * **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse. diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 3a8de5c..293d513 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -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 = ({ // 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 = ({ +
+
+

Journal anzeigen

+

+ 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. +

+
+ { + setJournalEnabled(v); + setJournalOn(v); + }} + title="Journal anzeigen" + /> +
+
{themes.map((t) => { const isSelected = activeTheme === t.id; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index f17ec21..5821d2b 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -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 = ({ onRequestMerge, onDocCreated, onDocRemoved, + variant = 'page', }) => { + const inJournal = variant === 'journal'; const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); const [blocks, setBlocks] = useState(() => @@ -245,7 +249,7 @@ export const DocumentEditor: React.FC = ({ 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 = ({ ); 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 = ({ } return ( -
- {/* Top Document Action Bar */} +
+ {/* Top Document Action Bar (the journal has one for all days) */} + {!inJournal && (
{onBackMobile && ( @@ -1017,6 +1026,7 @@ export const DocumentEditor: React.FC = ({
+ )} {/* In the trash: read-only with a way back */} {inTrash && ( @@ -1047,7 +1057,11 @@ export const DocumentEditor: React.FC = ({ {/* 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 */} -
+
{zoomedBlock ? ( /* ========================================================================= FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) @@ -1127,6 +1141,7 @@ export const DocumentEditor: React.FC = ({
) : ( = ({
diff --git a/frontend/src/features/workspace/JournalCalendar.tsx b/frontend/src/features/workspace/JournalCalendar.tsx new file mode 100644 index 0000000..8529b3c --- /dev/null +++ b/frontend/src/features/workspace/JournalCalendar.tsx @@ -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; + onSelect: (date: string) => void; +} + +const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + +export const JournalCalendar: React.FC = ({ 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 ( +
+
+ + + + +
+ +
+ {WEEKDAYS.map((w) => ( + {w} + ))} +
+
+ {weeks.flat().map((date, i) => + date ? ( + + ) : ( + + ) + )} +
+
+ ); +}; diff --git a/frontend/src/features/workspace/JournalView.tsx b/frontend/src/features/workspace/JournalView.tsx new file mode 100644 index 0000000..83bbd62 --- /dev/null +++ b/frontend/src/features/workspace/JournalView.tsx @@ -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; + 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 = ({ + 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([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()); + 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(null); + const sectionRefs = useRef(new Map()); + + // Jump: make sure the day is rendered, then scroll to it once its note is there + const pendingJump = useRef(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(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 ( +
+ {/* One header for all days: the day in view and its sharing */} +
+ + + {activeDate !== today && ( + + )} + +
+ + {showCalendar && ( +
+ { + setShowCalendar(false); + onJump(date); + }} + /> +
+ )} + +
+ {shown.map((date) => { + const docId = docIdByDate.get(date); + return ( +
{ + 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 ? ( + { + if (status === 'saved') onDaySaved(); + }} + /> + ) : ( +
{shortDateLabel(date)} wird geöffnet…
+ )} +
+ ); + })} + {limit < dates.length &&
} + {/* Room below the oldest day, so every day can be scrolled to the top */} +
+
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 9547957..1bdc613 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -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(({ docId, + compact = false, blocks, canEdit, levelOffset = 0, @@ -810,7 +813,7 @@ export const OutlinerTree = forwardRef(({ {canEdit && (
{ diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index c7d8808..a89a980 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -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 = ({ 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([]); + const [journalExtra, setJournalExtra] = useState([]); + 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(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(null); const [tagDetails, setTagDetails] = useState(null); const [tagNoteDraft, setTagNoteDraft] = useState(''); @@ -560,6 +592,34 @@ export const WorkspaceView: React.FC = ({ setMobileShowDetail(true); }, []); + const openJournalDay = useCallback(async (date: string): Promise => { + 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 = ({ } }; + // 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(folders); for (const d of docs) { @@ -977,7 +1043,7 @@ export const WorkspaceView: React.FC = ({ 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 = ({ } 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 = ({ {/* LEFT MASTER SIDEBAR */}