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
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
)
|
||||
|
||||
// Journal: one ordinary note per day, opened (and created on first use) by date. The web app shows the
|
||||
// days below each other with a calendar; the notes can be shared and searched like any other.
|
||||
|
||||
var journalMu sync.Mutex // one creation at a time, so a day never gets two notes
|
||||
|
||||
var (
|
||||
journalWeekdays = []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
|
||||
journalMonths = []string{"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
|
||||
)
|
||||
|
||||
// journalTitle: "Freitag, 30. Oktober 2026"
|
||||
func journalTitle(day time.Time) string {
|
||||
return fmt.Sprintf("%s, %d. %s %d", journalWeekdays[day.Weekday()], day.Day(), journalMonths[day.Month()-1], day.Year())
|
||||
}
|
||||
|
||||
func (s *Server) handleGetJournal(w http.ResponseWriter, r *http.Request) {
|
||||
days, err := s.store.JournalDays(auth.GetUserID(r.Context()))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"days": days})
|
||||
}
|
||||
|
||||
// handleOpenJournalDay returns the note of a day and creates it when there is none yet.
|
||||
func (s *Server) handleOpenJournalDay(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
date := chi.URLParam(r, "date")
|
||||
day, err := time.Parse("2006-01-02", date)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Datum (erwartet JJJJ-MM-TT)")
|
||||
return
|
||||
}
|
||||
journalMu.Lock()
|
||||
defer journalMu.Unlock()
|
||||
docID, err := s.store.JournalDocID(userID, date)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
|
||||
return
|
||||
}
|
||||
if docID != "" {
|
||||
if doc, err := s.store.GetDocumentByID(userID, docID); err == nil {
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
return
|
||||
}
|
||||
}
|
||||
doc, err := s.store.CreateDocument(userID, "", journalTitle(day), "📅", "", nil, nil, false)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
if err := s.store.SetJournalDay(userID, date, doc.ID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
|
||||
return
|
||||
}
|
||||
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
||||
writeJSON(w, http.StatusCreated, doc)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJournalDayIsCreatedOnceAndListed(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
me, token := newTestUser(t, store, "journal@example.com")
|
||||
|
||||
rec := sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var doc struct {
|
||||
ID, Title, Icon string
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &doc)
|
||||
if doc.Title != "Freitag, 30. Oktober 2026" || doc.Icon != "📅" {
|
||||
t.Fatalf("unexpected note: %+v", doc)
|
||||
}
|
||||
|
||||
// Opening the same day again returns the same note
|
||||
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||
var again struct{ ID string }
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||
if rec.Code != http.StatusOK || again.ID != doc.ID {
|
||||
t.Fatalf("second open: %d id %s want %s", rec.Code, again.ID, doc.ID)
|
||||
}
|
||||
|
||||
rec = sessionCall(router, "GET", "/api/v1/journal", token, "", "")
|
||||
var list struct {
|
||||
Days []struct {
|
||||
Date, DocID string
|
||||
Empty bool
|
||||
}
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if len(list.Days) != 1 || list.Days[0].Date != "2026-10-30" || list.Days[0].DocID != doc.ID || !list.Days[0].Empty {
|
||||
t.Fatalf("list: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Other people have their own journal
|
||||
_, otherToken := newTestUser(t, store, "andere@example.com")
|
||||
rec = sessionCall(router, "GET", "/api/v1/journal", otherToken, "", "")
|
||||
if rec.Code != http.StatusOK || rec.Body.String() == "" {
|
||||
t.Fatalf("other list: %d", rec.Code)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||
if len(list.Days) != 0 {
|
||||
t.Fatalf("other user must not see my journal: %+v", list.Days)
|
||||
}
|
||||
|
||||
// A day whose note went to the trash gets a fresh note
|
||||
if err := store.DeleteDocument(me.ID, doc.ID); err != nil {
|
||||
t.Fatalf("trash: %v", err)
|
||||
}
|
||||
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||
if rec.Code != http.StatusCreated || again.ID == doc.ID {
|
||||
t.Fatalf("after trash: %d id %s", rec.Code, again.ID)
|
||||
}
|
||||
|
||||
if rec := sessionCall(router, "POST", "/api/v1/journal/30.10.2026", token, "", ""); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad date: want 400, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJournalTitle(t *testing.T) {
|
||||
day := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
if got := journalTitle(day); got != "Sonntag, 1. März 2026" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||||
|
||||
r.Get("/api/v1/journal", s.handleGetJournal)
|
||||
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
|
||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Journal: one note per person and day. The notes are ordinary documents (sharing, search, sync work as
|
||||
// usual); this table only remembers which note belongs to which day.
|
||||
|
||||
type JournalDay struct {
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
DocID string `json:"docId"`
|
||||
Empty bool `json:"empty"` // no text yet (the journal still shows today and days jumped to)
|
||||
}
|
||||
|
||||
func (s *Store) migrateJournal() error {
|
||||
return s.execInTx(
|
||||
stmt(`CREATE TABLE IF NOT EXISTS journal_days (
|
||||
user_id TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, day)
|
||||
)`),
|
||||
stmt(`CREATE INDEX IF NOT EXISTS idx_journal_days_doc ON journal_days(doc_id)`),
|
||||
)
|
||||
}
|
||||
|
||||
// JournalDays lists the user's days whose note still exists (not in the trash), newest first.
|
||||
func (s *Store) JournalDays(userID string) ([]JournalDay, error) {
|
||||
rows, err := s.db.Query(`SELECT j.day, j.doc_id, TRIM(d.content) = ''
|
||||
FROM journal_days j JOIN documents d ON d.id = j.doc_id
|
||||
WHERE j.user_id = ? AND d.is_archived = 0
|
||||
ORDER BY j.day DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
days := []JournalDay{}
|
||||
for rows.Next() {
|
||||
var d JournalDay
|
||||
if err := rows.Scan(&d.Date, &d.DocID, &d.Empty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days = append(days, d)
|
||||
}
|
||||
return days, rows.Err()
|
||||
}
|
||||
|
||||
// JournalDocID returns the note of a day; "" when there is none or it was moved to the trash.
|
||||
func (s *Store) JournalDocID(userID, day string) (string, error) {
|
||||
var docID string
|
||||
err := s.db.QueryRow(`SELECT j.doc_id FROM journal_days j JOIN documents d ON d.id = j.doc_id
|
||||
WHERE j.user_id = ? AND j.day = ? AND d.is_archived = 0`, userID, day).Scan(&docID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return docID, err
|
||||
}
|
||||
|
||||
func (s *Store) SetJournalDay(userID, day, docID string) error {
|
||||
_, err := s.db.Exec(`INSERT INTO journal_days (user_id, day, doc_id) VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, day) DO UPDATE SET doc_id = excluded.doc_id`, userID, day, docID)
|
||||
return err
|
||||
}
|
||||
@@ -28,6 +28,7 @@ var migrations = []migration{
|
||||
{5, "Medien", (*Store).migrateMedia},
|
||||
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
||||
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||
{8, "Journal", (*Store).migrateJournal},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
Reference in new issue
Block a user