submission tracking

This commit is contained in:
TC
2026-06-14 18:18:55 +10:00
parent a78ae0fdc0
commit 32c57e02f1
10 changed files with 680 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
import { join, dirname, basename } from 'path'
import type { FileNode, RevisionMeta, SearchMatch, SearchFileResult } from '../renderer/types/editor'
import type { FileNode, RevisionMeta, SearchMatch, SearchFileResult, Submission } from '../renderer/types/editor'
import { getDraftRoot } from './globalConfig'
const hohoffDir = (): string => join(getDraftRoot(), '.hohoff')
@@ -9,6 +9,7 @@ const sessionFile = (): string => join(hohoffDir(), 'session.json')
const telemetryFile = (): string => join(hohoffDir(), 'telemetry.json')
const revisionsDir = (): string => join(hohoffDir(), 'revisions')
export const getStoryBiblePath = (): string => join(hohoffDir(), 'Story Bible.md')
const submissionsFile = (): string => join(hohoffDir(), 'submissions.json')
const STORY_BIBLE_TEMPLATE = `# Story Bible
@@ -556,3 +557,16 @@ export async function replaceInFiles(
}
return modified
}
export async function readSubmissions(): Promise<Submission[]> {
try {
return JSON.parse(await readFile(submissionsFile(), 'utf-8'))
} catch {
return []
}
}
export async function writeSubmissions(data: Submission[]): Promise<void> {
await mkdir(hohoffDir(), { recursive: true })
await writeFile(submissionsFile(), JSON.stringify(data, null, 2), 'utf-8')
}

View File

@@ -2,11 +2,11 @@ import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync, writeFileSync, unlinkSync } from 'fs'
import { extname, basename, join } from 'path'
import { tmpdir } from 'os'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS, readTelemetry } from './fileSystem'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles, readAllDraftFiles, readProjectConfig, writeProjectConfig, PROJECT_CONFIG_FIELDS, readTelemetry, readSubmissions, writeSubmissions } from './fileSystem'
import type { SearchOptions, ProjectConfig } from './fileSystem'
import { streamMessage, resetClient } from './aiService'
import { onWordSnapshot, flushTelemetry } from './telemetry'
import type { AIPayload, Attachment } from '../renderer/types/editor'
import type { AIPayload, Attachment, Submission } from '../renderer/types/editor'
import { readGlobalConfig, writeGlobalConfig, getProjectTitle, addRecentProject, updateRecentProjectTitle } from './globalConfig'
import type { GlobalConfig } from './globalConfig'
@@ -158,6 +158,14 @@ export function registerIpcHandlers(): void {
return await readTelemetry()
})
ipcMain.handle('submissions:read', async (): Promise<Submission[]> => {
return await readSubmissions()
})
ipcMain.handle('submissions:write', async (_event, data: Submission[]): Promise<void> => {
await writeSubmissions(data)
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try {
const storyBibleContent = (await readStoryBibleFile()) ?? undefined

View File

@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData } from '../renderer/types/editor'
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData, Submission } from '../renderer/types/editor'
interface SearchOptions {
caseSensitive: boolean
@@ -136,4 +136,10 @@ contextBridge.exposeInMainWorld('api', {
readTelemetry: (): Promise<TelemetryData> =>
ipcRenderer.invoke('telemetry:read'),
readSubmissions: (): Promise<Submission[]> =>
ipcRenderer.invoke('submissions:read'),
writeSubmissions: (data: Submission[]): Promise<void> =>
ipcRenderer.invoke('submissions:write', data),
})

View File

@@ -11,6 +11,7 @@ import { RevisionPanel } from './components/Revisions/RevisionPanel'
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
import { SettingsDialog } from './components/Settings/SettingsDialog'
import { HomeScreen } from './components/Home/HomeScreen'
import { SubmissionsScreen } from './components/Submissions/SubmissionsScreen'
import { useEditorStore } from './store/editorStore'
import './styles/app.css'
@@ -19,7 +20,7 @@ export default function App(): JSX.Element {
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
openProjectSearch, clearActiveFile, initPrefs, focusMode, toggleFocusMode,
showHome, goHome, setActiveFile
showHome, goHome, setActiveFile, showSubmissions, goSubmissions
} = useEditorStore()
const handleOpenStoryBible = async (): Promise<void> => {
@@ -215,12 +216,21 @@ export default function App(): JSX.Element {
>
<span className="sidebar-nav-icon">📖</span>
</button>
<button
className={`sidebar-nav-btn${showSubmissions ? ' active' : ''}`}
onClick={goSubmissions}
title="Submissions"
>
<span className="sidebar-nav-icon"></span>
</button>
</div>
<FileTree />
</aside>
<main className="editor-area" style={{ position: 'relative' }}>
{showHome ? (
<HomeScreen />
) : showSubmissions ? (
<SubmissionsScreen />
) : (
<>
<AnalysisToolbar />

View File

@@ -0,0 +1,347 @@
.subs-screen {
display: flex;
height: 100%;
overflow: hidden;
background: var(--page-bg);
}
/* ── List panel ─────────────────────────────────────────────────── */
.subs-list {
width: 240px;
min-width: 200px;
display: flex;
flex-direction: column;
border-right: 1px solid var(--border);
overflow: hidden;
}
.subs-list-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 16px 12px;
border-bottom: 1px solid var(--border);
}
.subs-list-title {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.subs-add-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 16px;
line-height: 1;
width: 22px;
height: 22px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.12s, border-color 0.12s;
}
.subs-add-btn:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.subs-list-items {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.subs-empty {
font-family: var(--font-sans);
font-size: 12px;
color: var(--text-muted);
padding: 20px 16px;
}
.subs-list-item {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 10px 16px;
cursor: pointer;
border-radius: 0;
transition: background 0.1s;
}
.subs-list-item:hover {
background: var(--active-bg);
}
.subs-list-item.selected {
background: var(--active-bg);
}
.subs-item-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.subs-item-recipient {
font-family: var(--font-sans);
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.subs-item-meta {
display: flex;
align-items: center;
gap: 6px;
}
.subs-item-type {
font-family: var(--font-sans);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.04em;
}
/* ── Status dots ────────────────────────────────────────────────── */
.subs-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.status-drafting { background: var(--text-muted); opacity: 0.4; }
.status-submitted { background: #6b9bd2; }
.status-awaiting { background: #c9924a; }
.status-shortlisted { background: #9b72cf; }
.status-rejected { background: var(--text-muted); opacity: 0.3; }
.status-accepted { background: #5a9e6f; }
.status-withdrawn { background: var(--text-muted); opacity: 0.25; }
/* ── Deadline badges ────────────────────────────────────────────── */
.deadline-badge {
font-family: var(--font-sans);
font-size: 10px;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.deadline-ok { color: var(--text-muted); }
.deadline-soon { color: #c9924a; }
.deadline-past { color: var(--text-muted); opacity: 0.5; }
/* ── Detail panel ───────────────────────────────────────────────── */
.subs-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.subs-no-selection {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.subs-new-btn {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 12px;
letter-spacing: 0.06em;
padding: 8px 18px;
border-radius: 5px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.subs-new-btn:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.subs-detail-header {
padding: 24px 32px 16px;
border-bottom: 1px solid var(--border);
}
.subs-detail-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.subs-field-row {
display: flex;
align-items: center;
gap: 10px;
}
.subs-recipient-input {
flex: 1;
background: none;
border: none;
border-bottom: 1px solid var(--border);
font-family: var(--font-serif);
font-size: 18px;
color: var(--text-primary);
padding: 2px 0;
outline: none;
transition: border-color 0.15s;
}
.subs-recipient-input:focus {
border-bottom-color: var(--text-muted);
}
.subs-recipient-input::placeholder {
color: var(--text-muted);
opacity: 0.5;
}
.subs-select {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
outline: none;
}
.subs-select:focus {
border-color: var(--text-muted);
}
.subs-dates-row {
align-items: center;
}
.subs-date-label {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-sans);
font-size: 11px;
color: var(--text-muted);
}
.subs-date-input {
background: none;
border: 1px solid var(--border);
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 11px;
padding: 3px 6px;
border-radius: 4px;
outline: none;
}
.subs-date-input:focus {
border-color: var(--text-muted);
}
.subs-delete-btn {
margin-left: auto;
background: none;
border: none;
color: var(--text-muted);
font-family: var(--font-sans);
font-size: 11px;
opacity: 0.5;
cursor: pointer;
padding: 4px 0;
transition: opacity 0.15s;
}
.subs-delete-btn:hover {
opacity: 1;
}
/* ── Body ───────────────────────────────────────────────────────── */
.subs-detail-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 24px 32px;
gap: 20px;
}
.subs-section {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
}
.subs-notes-section {
flex: 0 0 auto;
min-height: 80px;
max-height: 140px;
}
.subs-section-label {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted);
}
.subs-query-letter,
.subs-notes {
flex: 1;
background: none;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-primary);
font-family: var(--font-serif);
font-size: 14px;
line-height: 1.7;
padding: 14px 16px;
resize: none;
outline: none;
transition: border-color 0.15s;
}
.subs-query-letter:focus,
.subs-notes:focus {
border-color: var(--text-muted);
}
.subs-query-letter::placeholder,
.subs-notes::placeholder {
color: var(--text-muted);
opacity: 0.4;
}
.subs-notes {
font-family: var(--font-sans);
font-size: 13px;
line-height: 1.6;
}

View File

@@ -0,0 +1,253 @@
import { useEffect, useState, useCallback } from 'react'
import type { Submission, SubmissionStatus, SubmissionType } from '../../types/editor'
import './SubmissionsScreen.css'
const STATUS_LABELS: Record<SubmissionStatus, string> = {
drafting: 'Drafting',
submitted: 'Submitted',
awaiting: 'Awaiting',
shortlisted: 'Shortlisted',
rejected: 'Rejected',
accepted: 'Accepted',
withdrawn: 'Withdrawn',
}
const TYPE_LABELS: Record<SubmissionType, string> = {
agent: 'Agent',
publisher: 'Publisher',
competition: 'Competition',
other: 'Other',
}
function newSubmission(): Submission {
return {
id: crypto.randomUUID(),
recipient: '',
type: 'publisher',
dateSubmitted: null,
deadline: null,
status: 'drafting',
queryLetter: '',
notes: '',
createdAt: Date.now(),
updatedAt: Date.now(),
}
}
function daysUntil(isoDate: string): number {
const deadline = new Date(isoDate + 'T00:00:00').getTime()
const now = new Date().setHours(0, 0, 0, 0)
return Math.ceil((deadline - now) / 86400000)
}
function DeadlineBadge({ date }: { date: string }): JSX.Element {
const days = daysUntil(date)
const label = days === 0 ? 'today' : days === 1 ? '1 day' : days < 0 ? `${Math.abs(days)}d ago` : `${days}d`
const cls = days < 0 ? 'deadline-past' : days <= 7 ? 'deadline-soon' : 'deadline-ok'
return <span className={`deadline-badge ${cls}`}>{label}</span>
}
export function SubmissionsScreen(): JSX.Element {
const [submissions, setSubmissions] = useState<Submission[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [draft, setDraft] = useState<Submission | null>(null)
const [dirty, setDirty] = useState(false)
useEffect(() => {
window.api.readSubmissions().then(data => {
setSubmissions(data)
if (data.length > 0) {
setSelectedId(data[0].id)
setDraft(data[0])
}
}).catch(console.error)
}, [])
const save = useCallback(async (updated: Submission[]) => {
setSubmissions(updated)
await window.api.writeSubmissions(updated)
}, [])
function selectSubmission(sub: Submission): void {
if (dirty && draft) {
const updated = submissions.map(s => s.id === draft.id ? draft : s)
save(updated).catch(console.error)
setDirty(false)
}
setSelectedId(sub.id)
setDraft({ ...sub })
}
function addNew(): void {
if (dirty && draft) {
const updated = submissions.map(s => s.id === draft.id ? draft : s)
save(updated).catch(console.error)
setDirty(false)
}
const sub = newSubmission()
const updated = [sub, ...submissions]
setSubmissions(updated)
setSelectedId(sub.id)
setDraft(sub)
window.api.writeSubmissions(updated).catch(console.error)
}
function deleteSelected(): void {
if (!selectedId) return
const updated = submissions.filter(s => s.id !== selectedId)
save(updated).catch(console.error)
setDirty(false)
if (updated.length > 0) {
setSelectedId(updated[0].id)
setDraft({ ...updated[0] })
} else {
setSelectedId(null)
setDraft(null)
}
}
function updateDraft<K extends keyof Submission>(key: K, value: Submission[K]): void {
if (!draft) return
setDraft(prev => prev ? { ...prev, [key]: value, updatedAt: Date.now() } : prev)
setDirty(true)
}
function saveDraft(): void {
if (!draft) return
const updated = submissions.map(s => s.id === draft.id ? draft : s)
save(updated).catch(console.error)
setDirty(false)
}
const sorted = [...submissions].sort((a, b) => {
// Sort: active statuses first, then by deadline, then by updatedAt
const activeStatuses: SubmissionStatus[] = ['drafting', 'submitted', 'awaiting', 'shortlisted']
const aActive = activeStatuses.includes(a.status)
const bActive = activeStatuses.includes(b.status)
if (aActive !== bActive) return aActive ? -1 : 1
if (a.deadline && b.deadline) return a.deadline.localeCompare(b.deadline)
if (a.deadline) return -1
if (b.deadline) return 1
return b.updatedAt - a.updatedAt
})
return (
<div className="subs-screen">
<div className="subs-list">
<div className="subs-list-header">
<span className="subs-list-title">Submissions</span>
<button className="subs-add-btn" onClick={addNew} title="New submission">+</button>
</div>
<div className="subs-list-items">
{sorted.length === 0 && (
<div className="subs-empty">No submissions yet</div>
)}
{sorted.map(sub => (
<button
key={sub.id}
className={`subs-list-item${selectedId === sub.id ? ' selected' : ''}`}
onClick={() => selectSubmission(sub)}
>
<div className="subs-item-top">
<span className="subs-item-recipient">{sub.recipient || 'Untitled'}</span>
<span className={`subs-status-dot status-${sub.status}`} title={STATUS_LABELS[sub.status]} />
</div>
<div className="subs-item-meta">
<span className="subs-item-type">{TYPE_LABELS[sub.type]}</span>
{sub.deadline && <DeadlineBadge date={sub.deadline} />}
</div>
</button>
))}
</div>
</div>
<div className="subs-detail">
{!draft ? (
<div className="subs-no-selection">
<button className="subs-new-btn" onClick={addNew}>New submission</button>
</div>
) : (
<>
<div className="subs-detail-header">
<div className="subs-detail-fields">
<div className="subs-field-row">
<input
className="subs-recipient-input"
placeholder="Recipient name"
value={draft.recipient}
onChange={e => updateDraft('recipient', e.target.value)}
onBlur={saveDraft}
/>
<select
className="subs-select"
value={draft.type}
onChange={e => { updateDraft('type', e.target.value as SubmissionType); saveDraft() }}
>
{(Object.keys(TYPE_LABELS) as SubmissionType[]).map(t => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
))}
</select>
<select
className="subs-select"
value={draft.status}
onChange={e => { updateDraft('status', e.target.value as SubmissionStatus); saveDraft() }}
>
{(Object.keys(STATUS_LABELS) as SubmissionStatus[]).map(s => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
</div>
<div className="subs-field-row subs-dates-row">
<label className="subs-date-label">
<span>Submitted</span>
<input
type="date"
className="subs-date-input"
value={draft.dateSubmitted ?? ''}
onChange={e => updateDraft('dateSubmitted', e.target.value || null)}
onBlur={saveDraft}
/>
</label>
<label className="subs-date-label">
<span>Deadline</span>
<input
type="date"
className="subs-date-input"
value={draft.deadline ?? ''}
onChange={e => updateDraft('deadline', e.target.value || null)}
onBlur={saveDraft}
/>
</label>
<button className="subs-delete-btn" onClick={deleteSelected} title="Delete submission">Delete</button>
</div>
</div>
</div>
<div className="subs-detail-body">
<div className="subs-section">
<div className="subs-section-label">Query letter</div>
<textarea
className="subs-query-letter"
placeholder="Write your query letter for this submission…"
value={draft.queryLetter}
onChange={e => updateDraft('queryLetter', e.target.value)}
onBlur={saveDraft}
/>
</div>
<div className="subs-section subs-notes-section">
<div className="subs-section-label">Notes</div>
<textarea
className="subs-notes"
placeholder="Contacts, requirements, response…"
value={draft.notes}
onChange={e => updateDraft('notes', e.target.value)}
onBlur={saveDraft}
/>
</div>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -111,6 +111,11 @@ interface EditorState {
goHome: () => void
leaveHome: () => void
// Submissions screen
showSubmissions: boolean
goSubmissions: () => void
leaveSubmissions: () => void
// Session persistence
loadSession: () => Promise<void>
}
@@ -190,6 +195,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
activeFileContent: content,
isDirty: false,
showHome: false,
showSubmissions: false,
chatHistory: existing,
annotations: savedAnnotationState?.annotations.filter(a => !a.applied && !a.dismissed) ?? [],
analysisMode: savedAnnotationState?.mode ?? 'none'
@@ -679,9 +685,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
clearPendingScrollToLine: () => set({ pendingScrollToLine: null }),
showHome: false,
goHome: () => set({ showHome: true }),
goHome: () => set({ showHome: true, showSubmissions: false }),
leaveHome: () => set({ showHome: false }),
showSubmissions: false,
goSubmissions: () => set({ showSubmissions: true, showHome: false }),
leaveSubmissions: () => set({ showSubmissions: false }),
loadSession: async () => {
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
if (!api) return

View File

@@ -106,3 +106,19 @@ export interface SearchFileResult {
relativePath: string
matches: SearchMatch[]
}
export type SubmissionType = 'agent' | 'publisher' | 'competition' | 'other'
export type SubmissionStatus = 'drafting' | 'submitted' | 'awaiting' | 'shortlisted' | 'rejected' | 'accepted' | 'withdrawn'
export interface Submission {
id: string
recipient: string
type: SubmissionType
dateSubmitted: string | null // ISO date YYYY-MM-DD
deadline: string | null // ISO date YYYY-MM-DD
status: SubmissionStatus
queryLetter: string
notes: string
createdAt: number
updatedAt: number
}

View File

@@ -1,4 +1,4 @@
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig } from './editor'
import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult, GlobalConfig, TelemetryData, Submission } from './editor'
import type { ExportOptions } from '../components/Export/ExportDialog'
interface SearchOptions {
@@ -40,6 +40,14 @@ declare global {
pickProjectFolder: () => Promise<string | null>
exportPDF: (content: string, fileName: string) => Promise<void>
exportProjectPDF: (opts: ExportOptions) => Promise<void>
readAllDraftFiles: () => Promise<{ relativePath: string; content: string }[]>
trackWordSnapshot: (filePath: string, wordCount: number) => Promise<void>
flushTelemetry: () => Promise<void>
readTelemetry: () => Promise<TelemetryData>
readSubmissions: () => Promise<Submission[]>
writeSubmissions: (data: Submission[]) => Promise<void>
readSession: () => Promise<Record<string, unknown>>
writeSession: (data: Record<string, unknown>) => Promise<void>
}
}
}

File diff suppressed because one or more lines are too long