develop #56

Merged
TheFurya merged 22 commits from develop into main 2026-03-20 20:02:26 +01:00
13 changed files with 733 additions and 4 deletions
Showing only changes of commit 088cd35002 - Show all commits

View File

@@ -1,10 +1,11 @@
---
# nuzlocke-tracker-bw1m
title: Errors
status: todo
status: completed
type: epic
priority: normal
created_at: 2026-03-20T15:19:43Z
updated_at: 2026-03-20T15:19:43Z
updated_at: 2026-03-20T15:39:27Z
---
Container for crash and blocker beans created by Talos.

View File

@@ -0,0 +1,29 @@
---
# nuzlocke-tracker-d5ht
title: 'Bug: TypeScript build fails due to optional property type mismatches in journal components'
status: todo
type: bug
priority: high
created_at: 2026-03-20T15:39:00Z
updated_at: 2026-03-20T15:39:00Z
parent: nuzlocke-tracker-bw1m
---
The frontend TypeScript build fails with 3 errors due to `exactOptionalPropertyTypes` being enabled.
## Errors
1. `JournalEntryPage.tsx:76` - `bossResults` and `bosses` props passed as `undefined` to `JournalEditor`
2. `JournalEntryPage.tsx:92` - `bossResult` and `boss` props passed as `undefined` to `JournalEntryView`
3. `RunEncounters.tsx:1170` - `bossResults` and `bosses` props passed as `undefined` to `JournalSection`
## Root Cause
Optional props in interfaces are declared as `prop?: Type` but callers pass `undefined` values from React Query hooks. With `exactOptionalPropertyTypes: true`, TypeScript requires `prop?: Type | undefined` to allow explicit `undefined` values.
## Fix
Update the interfaces in these files:
- `JournalEditor.tsx` lines 9-10: change to `bossResults?: BossResult[] | undefined` and `bosses?: BossBattle[] | undefined`
- `JournalEntryView.tsx` lines 8-9: change to `bossResult?: BossResult | null | undefined` and `boss?: BossBattle | null | undefined`
- `JournalSection.tsx` lines 9-10: change to `bossResults?: BossResult[] | undefined` and `bosses?: BossBattle[] | undefined`

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-oar4
title: Ko-fi Integration
status: draft
type: feature
priority: deferred
created_at: 2026-03-20T15:38:23Z
updated_at: 2026-03-20T15:38:23Z
---
Add Ko-fi integration to allow visitors to contribute toward hosting costs. This is not about monetization — it's a way for users who enjoy the tool to optionally help cover server/infrastructure expenses.
## Open Questions
- [ ] Where should the Ko-fi link/button live? (footer, about page, dedicated page, or subtle banner?)
- [ ] Should it be a simple outbound link to a Ko-fi page, or use Ko-fi's embeddable widget/overlay?
- [ ] Should there be any acknowledgment for supporters (e.g., a thank-you page, supporter list)?
- [ ] Should this be gated behind user auth (only shown to logged-in users) or visible to everyone?
- [ ] Any legal/tax considerations to document?

View File

@@ -1,11 +1,11 @@
---
# nuzlocke-tracker-t90q
title: 'Crash: Backend: Journal entries model, API, and migration'
status: todo
status: completed
type: bug
priority: high
created_at: 2026-03-20T15:30:02Z
updated_at: 2026-03-20T15:30:24Z
updated_at: 2026-03-20T15:39:13Z
parent: nuzlocke-tracker-bw1m
blocking:
- nuzlocke-tracker-vmto
@@ -18,3 +18,7 @@ Manual review required before retrying.
Bean: nuzlocke-tracker-vmto
Title: Backend: Journal entries model, API, and migration
## Resolution
The underlying bean (nuzlocke-tracker-vmto) was already completed before the crash was detected. All backend work for journal entries is implemented and functional. A separate bug bean (nuzlocke-tracker-d5ht) was created for frontend TypeScript errors discovered during review.

View File

@@ -0,0 +1,37 @@
import { api } from './client'
import type {
JournalEntry,
CreateJournalEntryInput,
UpdateJournalEntryInput,
} from '../types/journal'
export function getJournalEntries(
runId: number,
bossResultId?: number
): Promise<JournalEntry[]> {
const params = bossResultId != null ? `?boss_result_id=${bossResultId}` : ''
return api.get(`/runs/${runId}/journal${params}`)
}
export function getJournalEntry(runId: number, entryId: string): Promise<JournalEntry> {
return api.get(`/runs/${runId}/journal/${entryId}`)
}
export function createJournalEntry(
runId: number,
data: CreateJournalEntryInput
): Promise<JournalEntry> {
return api.post(`/runs/${runId}/journal`, data)
}
export function updateJournalEntry(
runId: number,
entryId: string,
data: UpdateJournalEntryInput
): Promise<JournalEntry> {
return api.put(`/runs/${runId}/journal/${entryId}`, data)
}
export function deleteJournalEntry(runId: number, entryId: string): Promise<void> {
return api.del(`/runs/${runId}/journal/${entryId}`)
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { JournalEntry } from '../../types/journal'
import type { BossResult, BossBattle } from '../../types/game'
interface JournalEditorProps {
entry?: JournalEntry | null
bossResults?: BossResult[]
bosses?: BossBattle[]
onSave: (data: { title: string; body: string; bossResultId: number | null }) => void
onDelete?: () => void
onCancel: () => void
isSaving?: boolean
isDeleting?: boolean
}
type Tab = 'write' | 'preview'
export function JournalEditor({
entry,
bossResults = [],
bosses = [],
onSave,
onDelete,
onCancel,
isSaving = false,
isDeleting = false,
}: JournalEditorProps) {
const [title, setTitle] = useState(entry?.title ?? '')
const [body, setBody] = useState(entry?.body ?? '')
const [bossResultId, setBossResultId] = useState<number | null>(entry?.bossResultId ?? null)
const [activeTab, setActiveTab] = useState<Tab>('write')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
useEffect(() => {
if (entry) {
setTitle(entry.title)
setBody(entry.body)
setBossResultId(entry.bossResultId)
}
}, [entry])
const bossMap = new Map(bosses.map((b) => [b.id, b]))
const getBossName = (result: BossResult): string => {
const boss = bossMap.get(result.bossBattleId)
return boss?.name ?? `Boss #${result.bossBattleId}`
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !body.trim()) return
onSave({ title: title.trim(), body: body.trim(), bossResultId })
}
const handleDelete = () => {
if (showDeleteConfirm) {
onDelete?.()
} else {
setShowDeleteConfirm(true)
}
}
const canSave = title.trim().length > 0 && body.trim().length > 0
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="journal-title" className="block text-sm font-medium text-text-secondary mb-1">
Title
</label>
<input
id="journal-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Entry title..."
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-lg text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSaving}
/>
</div>
<div>
<label htmlFor="journal-boss" className="block text-sm font-medium text-text-secondary mb-1">
Linked Boss Battle (optional)
</label>
<select
id="journal-boss"
value={bossResultId ?? ''}
onChange={(e) => setBossResultId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-lg text-text-primary focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isSaving}
>
<option value="">None</option>
{bossResults.map((result) => (
<option key={result.id} value={result.id}>
{getBossName(result)} ({result.result === 'won' ? 'Victory' : 'Defeat'})
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<button
type="button"
onClick={() => setActiveTab('write')}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'write'
? 'bg-surface-2 text-text-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
Write
</button>
<button
type="button"
onClick={() => setActiveTab('preview')}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
activeTab === 'preview'
? 'bg-surface-2 text-text-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
Preview
</button>
</div>
{activeTab === 'write' ? (
<textarea
id="journal-body"
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Write your journal entry in markdown..."
rows={12}
className="w-full px-3 py-2 bg-surface-1 border border-border rounded-lg text-text-primary placeholder:text-text-tertiary focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm resize-y"
disabled={isSaving}
/>
) : (
<div className="min-h-[300px] px-4 py-3 bg-surface-1 border border-border rounded-lg overflow-auto">
{body.trim() ? (
<div className="prose prose-invert light:prose max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{body}</ReactMarkdown>
</div>
) : (
<p className="text-text-tertiary italic">Nothing to preview</p>
)}
</div>
)}
</div>
<div className="flex items-center justify-between pt-2">
<div>
{entry && onDelete && (
<button
type="button"
onClick={handleDelete}
disabled={isDeleting}
className={`px-4 py-2 rounded-lg transition-colors ${
showDeleteConfirm
? 'bg-red-600 text-white hover:bg-red-700'
: 'text-red-400 hover:text-red-300 hover:bg-red-900/20'
}`}
>
{isDeleting ? 'Deleting...' : showDeleteConfirm ? 'Click again to confirm' : 'Delete'}
</button>
)}
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 text-text-secondary hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={!canSave || isSaving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isSaving ? 'Saving...' : entry ? 'Save Changes' : 'Create Entry'}
</button>
</div>
</div>
</form>
)
}

View File

@@ -0,0 +1,83 @@
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { JournalEntry } from '../../types/journal'
import type { BossResult, BossBattle } from '../../types/game'
interface JournalEntryViewProps {
entry: JournalEntry
bossResult?: BossResult | null
boss?: BossBattle | null
onEdit?: () => void
onBack?: () => void
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
export function JournalEntryView({
entry,
bossResult,
boss,
onEdit,
onBack,
}: JournalEntryViewProps) {
return (
<article className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6">
{onBack && (
<button
type="button"
onClick={onBack}
className="text-text-secondary hover:text-text-primary transition-colors flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back to Journal
</button>
)}
{onEdit && (
<button
type="button"
onClick={onEdit}
className="px-4 py-2 bg-surface-2 text-text-primary rounded-lg hover:bg-surface-3 transition-colors"
>
Edit
</button>
)}
</div>
<header className="mb-8">
<h1 className="text-3xl font-bold text-text-primary mb-3">{entry.title}</h1>
<div className="flex items-center gap-4 text-sm text-text-secondary">
<time>{formatDate(entry.createdAt)}</time>
{entry.updatedAt !== entry.createdAt && (
<span className="text-text-tertiary">(edited {formatDate(entry.updatedAt)})</span>
)}
</div>
{boss && bossResult && (
<div className="mt-3">
<span className="inline-flex items-center gap-2 px-3 py-1 bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-800 rounded-full text-sm">
<span>Linked to:</span>
<span className="font-medium">{boss.name}</span>
<span className={bossResult.result === 'won' ? 'text-green-400' : 'text-red-400'}>
({bossResult.result === 'won' ? 'Victory' : 'Defeat'})
</span>
</span>
</div>
)}
</header>
<div className="prose prose-invert light:prose max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{entry.body}</ReactMarkdown>
</div>
</article>
)
}

View File

@@ -0,0 +1,102 @@
import { Link } from 'react-router-dom'
import type { JournalEntry } from '../../types/journal'
import type { BossResult, BossBattle } from '../../types/game'
interface JournalListProps {
entries: JournalEntry[]
runId: number
bossResults?: BossResult[]
bosses?: BossBattle[]
onNewEntry?: () => void
}
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
function getPreviewSnippet(body: string, maxLength = 120): string {
const stripped = body.replace(/[#*_`~[\]]/g, '').replace(/\n+/g, ' ').trim()
if (stripped.length <= maxLength) return stripped
return stripped.slice(0, maxLength).trim() + '...'
}
export function JournalList({
entries,
runId,
bossResults = [],
bosses = [],
onNewEntry,
}: JournalListProps) {
const bossResultMap = new Map(bossResults.map((br) => [br.id, br]))
const bossMap = new Map(bosses.map((b) => [b.id, b]))
const getBossName = (bossResultId: number | null): string | null => {
if (bossResultId == null) return null
const result = bossResultMap.get(bossResultId)
if (!result) return null
const boss = bossMap.get(result.bossBattleId)
return boss?.name ?? null
}
if (entries.length === 0) {
return (
<div className="text-center py-12">
<p className="text-text-secondary mb-4">No journal entries yet.</p>
{onNewEntry && (
<button
type="button"
onClick={onNewEntry}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Write your first entry
</button>
)}
</div>
)
}
return (
<div className="space-y-3">
{onNewEntry && (
<div className="flex justify-end mb-4">
<button
type="button"
onClick={onNewEntry}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
New Entry
</button>
</div>
)}
{entries.map((entry) => {
const bossName = getBossName(entry.bossResultId)
return (
<Link
key={entry.id}
to={`/runs/${runId}/journal/${entry.id}`}
className="block p-4 bg-surface-1 rounded-lg hover:bg-surface-2 transition-colors border border-border"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-text-primary truncate">{entry.title}</h3>
<p className="text-sm text-text-secondary mt-1">{getPreviewSnippet(entry.body)}</p>
</div>
<div className="flex flex-col items-end shrink-0 gap-1">
<time className="text-xs text-text-tertiary">{formatDate(entry.createdAt)}</time>
{bossName && (
<span className="text-xs px-2 py-0.5 bg-purple-900/40 text-purple-300 light:bg-purple-100 light:text-purple-800 rounded-full">
{bossName}
</span>
)}
</div>
</div>
</Link>
)
})}
</div>
)
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react'
import { useJournalEntries, useCreateJournalEntry } from '../../hooks/useJournal'
import { JournalList } from './JournalList'
import { JournalEditor } from './JournalEditor'
import type { BossResult, BossBattle } from '../../types/game'
interface JournalSectionProps {
runId: number
bossResults?: BossResult[]
bosses?: BossBattle[]
}
type Mode = 'list' | 'new'
export function JournalSection({ runId, bossResults = [], bosses = [] }: JournalSectionProps) {
const [mode, setMode] = useState<Mode>('list')
const { data: entries, isLoading, error } = useJournalEntries(runId)
const createEntry = useCreateJournalEntry(runId)
const handleNewEntry = () => {
setMode('new')
}
const handleSave = (data: { title: string; body: string; bossResultId: number | null }) => {
createEntry.mutate(data, {
onSuccess: () => setMode('list'),
})
}
const handleCancel = () => {
setMode('list')
}
if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error) {
return (
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
Failed to load journal entries.
</div>
)
}
if (mode === 'new') {
return (
<div>
<h2 className="text-xl font-semibold text-text-primary mb-4">New Journal Entry</h2>
<JournalEditor
bossResults={bossResults}
bosses={bosses}
onSave={handleSave}
onCancel={handleCancel}
isSaving={createEntry.isPending}
/>
</div>
)
}
return (
<div>
<JournalList
entries={entries ?? []}
runId={runId}
bossResults={bossResults}
bosses={bosses}
onNewEntry={handleNewEntry}
/>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { JournalList } from './JournalList'
export { JournalEditor } from './JournalEditor'
export { JournalEntryView } from './JournalEntryView'
export { JournalSection } from './JournalSection'

View File

@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
getJournalEntries,
getJournalEntry,
createJournalEntry,
updateJournalEntry,
deleteJournalEntry,
} from '../api/journal'
import type { CreateJournalEntryInput, UpdateJournalEntryInput } from '../types/journal'
export function useJournalEntries(runId: number, bossResultId?: number) {
return useQuery({
queryKey: ['runs', runId, 'journal', { bossResultId }],
queryFn: () => getJournalEntries(runId, bossResultId),
})
}
export function useJournalEntry(runId: number, entryId: string | null) {
return useQuery({
queryKey: ['runs', runId, 'journal', entryId],
queryFn: () => getJournalEntry(runId, entryId!),
enabled: entryId != null,
})
}
export function useCreateJournalEntry(runId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: CreateJournalEntryInput) => createJournalEntry(runId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['runs', runId, 'journal'] })
toast.success('Journal entry created')
},
onError: (err) => toast.error(`Failed to create entry: ${err.message}`),
})
}
export function useUpdateJournalEntry(runId: number, entryId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (data: UpdateJournalEntryInput) => updateJournalEntry(runId, entryId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['runs', runId, 'journal'] })
toast.success('Journal entry saved')
},
onError: (err) => toast.error(`Failed to save entry: ${err.message}`),
})
}
export function useDeleteJournalEntry(runId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: (entryId: string) => deleteJournalEntry(runId, entryId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['runs', runId, 'journal'] })
toast.success('Journal entry deleted')
},
onError: (err) => toast.error(`Failed to delete entry: ${err.message}`),
})
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useRun } from '../hooks/useRuns'
import { useBossResults, useGameBosses } from '../hooks/useBosses'
import {
useJournalEntry,
useUpdateJournalEntry,
useDeleteJournalEntry,
} from '../hooks/useJournal'
import { JournalEntryView } from '../components/journal/JournalEntryView'
import { JournalEditor } from '../components/journal/JournalEditor'
export function JournalEntryPage() {
const { runId, entryId } = useParams<{ runId: string; entryId: string }>()
const navigate = useNavigate()
const runIdNum = Number(runId)
const [isEditing, setIsEditing] = useState(false)
const { data: run, isLoading: runLoading } = useRun(runIdNum)
const { data: entry, isLoading: entryLoading, error } = useJournalEntry(runIdNum, entryId ?? null)
const { data: bossResults } = useBossResults(runIdNum)
const { data: bosses } = useGameBosses(run?.gameId ?? null)
const updateEntry = useUpdateJournalEntry(runIdNum, entryId ?? '')
const deleteEntry = useDeleteJournalEntry(runIdNum)
const isLoading = runLoading || entryLoading
if (isLoading) {
return (
<div className="max-w-3xl mx-auto p-8 flex justify-center">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
)
}
if (error || !entry || !run) {
return (
<div className="max-w-3xl mx-auto p-8">
<div className="rounded-lg bg-status-failed-bg p-4 text-status-failed">
Failed to load journal entry.
</div>
<button
type="button"
onClick={() => navigate(`/runs/${runId}`)}
className="inline-block mt-4 text-blue-600 hover:underline"
>
Back to run
</button>
</div>
)
}
const linkedBossResult = entry.bossResultId
? bossResults?.find((br) => br.id === entry.bossResultId)
: null
const linkedBoss = linkedBossResult
? bosses?.find((b) => b.id === linkedBossResult.bossBattleId)
: null
const handleSave = (data: { title: string; body: string; bossResultId: number | null }) => {
updateEntry.mutate(data, {
onSuccess: () => setIsEditing(false),
})
}
const handleDelete = () => {
deleteEntry.mutate(entry.id, {
onSuccess: () => navigate(`/runs/${runId}?tab=journal`),
})
}
if (isEditing) {
return (
<div className="max-w-3xl mx-auto p-8">
<h1 className="text-2xl font-bold text-text-primary mb-6">Edit Journal Entry</h1>
<JournalEditor
entry={entry}
bossResults={bossResults}
bosses={bosses}
onSave={handleSave}
onDelete={handleDelete}
onCancel={() => setIsEditing(false)}
isSaving={updateEntry.isPending}
isDeleting={deleteEntry.isPending}
/>
</div>
)
}
return (
<div className="max-w-3xl mx-auto p-8">
<JournalEntryView
entry={entry}
bossResult={linkedBossResult}
boss={linkedBoss}
onEdit={() => setIsEditing(true)}
onBack={() => navigate(`/runs/${runId}?tab=journal`)}
/>
</div>
)
}

View File

@@ -0,0 +1,21 @@
export interface JournalEntry {
id: string // UUID
runId: number
bossResultId: number | null
title: string
body: string
createdAt: string
updatedAt: string
}
export interface CreateJournalEntryInput {
bossResultId?: number | null
title: string
body: string
}
export interface UpdateJournalEntryInput {
bossResultId?: number | null
title?: string
body?: string
}