feat(cycles): symptom management UI

Add a modal to create, rename, recolor, and delete custom symptoms
without leaving the workbench view. Opens from a small "Verwalten"
button next to the Symptoms section header.

- New SymptomManager.svelte component wired to symptomsStore
- Inline edit mode with name + category select
- Delete with confirm (shows current name)
- i18n keys for manager strings + symptomCategory labels, de/en
  translated, it/fr/es mirrored from en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 16:19:29 +02:00
parent b97e2b5c6e
commit 59a9c05872
7 changed files with 444 additions and 9 deletions

View file

@ -59,6 +59,23 @@
},
"empty": "Tippe oben auf eine Blutungsstärke, um deinen ersten Tag festzuhalten — oder starte direkt eine Periode.",
"confirm": {
"deleteEntry": "Tageseintrag vom {date} wirklich löschen?"
"deleteEntry": "Tageseintrag vom {date} wirklich löschen?",
"deleteSymptom": "Symptom \"{name}\" löschen?"
},
"symptomCategory": {
"physical": "Körperlich",
"emotional": "Emotional",
"other": "Sonstiges"
},
"symptomManager": {
"title": "Symptome verwalten",
"open": "Verwalten",
"newNamePlaceholder": "Neues Symptom…",
"add": "Hinzufügen",
"edit": "Bearbeiten",
"delete": "Löschen",
"save": "Speichern",
"cancel": "Abbrechen",
"empty": "Noch keine Symptome angelegt."
}
}

View file

@ -59,6 +59,23 @@
},
"empty": "Tap a flow level above to log your first day — or start a period directly.",
"confirm": {
"deleteEntry": "Really delete the entry from {date}?"
"deleteEntry": "Really delete the entry from {date}?",
"deleteSymptom": "Delete symptom \"{name}\"?"
},
"symptomCategory": {
"physical": "Physical",
"emotional": "Emotional",
"other": "Other"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",
"newNamePlaceholder": "New symptom…",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"empty": "No symptoms yet."
}
}

View file

@ -59,6 +59,23 @@
},
"empty": "Tap a flow level above to log your first day — or start a period directly.",
"confirm": {
"deleteEntry": "Really delete the entry from {date}?"
"deleteEntry": "Really delete the entry from {date}?",
"deleteSymptom": "Delete symptom \"{name}\"?"
},
"symptomCategory": {
"physical": "Physical",
"emotional": "Emotional",
"other": "Other"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",
"newNamePlaceholder": "New symptom…",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"empty": "No symptoms yet."
}
}

View file

@ -59,6 +59,23 @@
},
"empty": "Tap a flow level above to log your first day — or start a period directly.",
"confirm": {
"deleteEntry": "Really delete the entry from {date}?"
"deleteEntry": "Really delete the entry from {date}?",
"deleteSymptom": "Delete symptom \"{name}\"?"
},
"symptomCategory": {
"physical": "Physical",
"emotional": "Emotional",
"other": "Other"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",
"newNamePlaceholder": "New symptom…",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"empty": "No symptoms yet."
}
}

View file

@ -59,6 +59,23 @@
},
"empty": "Tap a flow level above to log your first day — or start a period directly.",
"confirm": {
"deleteEntry": "Really delete the entry from {date}?"
"deleteEntry": "Really delete the entry from {date}?",
"deleteSymptom": "Delete symptom \"{name}\"?"
},
"symptomCategory": {
"physical": "Physical",
"emotional": "Emotional",
"other": "Other"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",
"newNamePlaceholder": "New symptom…",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"save": "Save",
"cancel": "Cancel",
"empty": "No symptoms yet."
}
}

View file

@ -21,6 +21,7 @@
predictNextPeriodStart,
} from './utils/prediction';
import { FLOW_COLORS, MOOD_COLORS, PHASE_COLORS, type Flow, type Mood } from './types';
import SymptomManager from './components/SymptomManager.svelte';
import type { ViewProps } from '$lib/app-registry';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -48,6 +49,9 @@
const FLOWS: Flow[] = ['none', 'spotting', 'light', 'medium', 'heavy'];
const MOODS: Mood[] = ['great', 'good', 'neutral', 'low', 'bad'];
// ─ Symptom manager modal state
let symptomManagerOpen = $state(false);
// ─ Editing state — defaults to today, can be switched to any past day
let editingDate = $state(todayIso);
let editingLog = $derived(logs.find((l) => l.logDate === editingDate) ?? null);
@ -230,9 +234,14 @@
</section>
<!-- Symptoms -->
{#if symptoms.length > 0}
<section class="log-section">
<section class="log-section">
<div class="section-header">
<h3 class="section-label">{$_('cycles.label.symptoms')}</h3>
<button class="section-action" onclick={() => (symptomManagerOpen = true)}>
{$_('cycles.symptomManager.open')}
</button>
</div>
{#if symptoms.length > 0}
<div class="row">
{#each symptoms as sym}
<button
@ -246,8 +255,8 @@
</button>
{/each}
</div>
</section>
{/if}
{/if}
</section>
<!-- Temperature & Notes -->
<section class="log-section">
@ -346,6 +355,8 @@
{/if}
</div>
<SymptomManager visible={symptomManagerOpen} onClose={() => (symptomManagerOpen = false)} />
<style>
.app-view {
display: flex;
@ -491,6 +502,25 @@
font-weight: 600;
margin: 0;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.section-action {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.625rem;
color: #ec4899;
background: transparent;
border: 1px solid rgba(236, 72, 153, 0.3);
cursor: pointer;
transition: all 0.15s;
}
.section-action:hover {
background: rgba(236, 72, 153, 0.1);
}
:global(.dark) .section-label {
color: #6b7280;
}

View file

@ -0,0 +1,320 @@
<!--
Symptom Manager — Modal to create, rename, recolor, and delete cycle symptoms.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useAllSymptoms } from '../queries';
import { symptomsStore } from '../stores/symptoms.svelte';
import type { CycleSymptom, SymptomCategory } from '../types';
import { Modal } from '@mana/shared-ui';
interface Props {
visible: boolean;
onClose: () => void;
}
const { visible, onClose }: Props = $props();
const symptoms$ = useAllSymptoms();
const symptoms = $derived(symptoms$.value);
const CATEGORIES: SymptomCategory[] = ['physical', 'emotional', 'other'];
let newName = $state('');
let newCategory = $state<SymptomCategory>('physical');
let editingId = $state<string | null>(null);
let editName = $state('');
let editCategory = $state<SymptomCategory>('physical');
async function handleCreate(e: Event) {
e.preventDefault();
const trimmed = newName.trim();
if (!trimmed) return;
await symptomsStore.createSymptom({ name: trimmed, category: newCategory });
newName = '';
newCategory = 'physical';
}
function startEdit(sym: CycleSymptom) {
editingId = sym.id;
editName = sym.name;
editCategory = sym.category;
}
async function saveEdit() {
if (!editingId) return;
const trimmed = editName.trim();
if (!trimmed) {
editingId = null;
return;
}
await symptomsStore.updateSymptom(editingId, { name: trimmed, category: editCategory });
editingId = null;
}
function cancelEdit() {
editingId = null;
}
async function handleDelete(sym: CycleSymptom) {
const ok = confirm(
$_('cycles.confirm.deleteSymptom', { values: { name: sym.name } }) || `"${sym.name}" löschen?`
);
if (!ok) return;
await symptomsStore.deleteSymptom(sym.id);
}
</script>
<Modal {visible} {onClose} title={$_('cycles.symptomManager.title')} maxWidth="md">
<div class="sm-content">
<!-- Create form -->
<form class="sm-create" onsubmit={handleCreate}>
<input
class="sm-input"
type="text"
placeholder={$_('cycles.symptomManager.newNamePlaceholder')}
bind:value={newName}
/>
<select class="sm-select" bind:value={newCategory}>
{#each CATEGORIES as cat}
<option value={cat}>{$_(`cycles.symptomCategory.${cat}`)}</option>
{/each}
</select>
<button class="sm-add" type="submit" disabled={!newName.trim()}>
{$_('cycles.symptomManager.add')}
</button>
</form>
<!-- Symptom list -->
{#if symptoms.length === 0}
<p class="sm-empty">{$_('cycles.symptomManager.empty')}</p>
{:else}
<ul class="sm-list">
{#each symptoms as sym (sym.id)}
<li class="sm-row" class:editing={editingId === sym.id}>
{#if editingId === sym.id}
<input
class="sm-input"
type="text"
bind:value={editName}
onkeydown={(e) => e.key === 'Enter' && saveEdit()}
/>
<select class="sm-select" bind:value={editCategory}>
{#each CATEGORIES as cat}
<option value={cat}>{$_(`cycles.symptomCategory.${cat}`)}</option>
{/each}
</select>
<div class="sm-actions">
<button class="sm-btn primary" type="button" onclick={saveEdit}>
{$_('cycles.symptomManager.save')}
</button>
<button class="sm-btn" type="button" onclick={cancelEdit}>
{$_('cycles.symptomManager.cancel')}
</button>
</div>
{:else}
<span
class="sm-dot"
style="background: {sym.color ?? 'var(--color-muted-foreground, #9ca3af)'}"
></span>
<div class="sm-info">
<span class="sm-name">{sym.name}</span>
<span class="sm-cat">
{$_(`cycles.symptomCategory.${sym.category}`)}
{#if sym.count > 0}
· {sym.count}
{/if}
</span>
</div>
<div class="sm-actions">
<button class="sm-btn" type="button" onclick={() => startEdit(sym)}>
{$_('cycles.symptomManager.edit')}
</button>
<button class="sm-btn danger" type="button" onclick={() => handleDelete(sym)}>
{$_('cycles.symptomManager.delete')}
</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
</Modal>
<style>
.sm-content {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 0.25rem 0;
}
.sm-create {
display: flex;
gap: 0.375rem;
align-items: center;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .sm-create {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.sm-input {
flex: 1;
min-width: 0;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.75rem;
color: #374151;
outline: none;
font-family: inherit;
}
.sm-input:focus {
border-color: #ec4899;
}
:global(.dark) .sm-input {
border-color: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.sm-select {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 0.6875rem;
color: #374151;
outline: none;
font-family: inherit;
}
:global(.dark) .sm-select {
border-color: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
color-scheme: dark;
}
.sm-add {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
background: #ec4899;
color: white;
border: none;
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
}
.sm-add:hover:not(:disabled) {
filter: brightness(1.1);
}
.sm-add:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sm-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
max-height: 22rem;
overflow-y: auto;
}
.sm-row {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
:global(.dark) .sm-row {
border-bottom-color: rgba(255, 255, 255, 0.04);
}
.sm-row.editing {
background: rgba(236, 72, 153, 0.06);
border-radius: 0.375rem;
padding: 0.5rem;
}
.sm-dot {
width: 10px;
height: 10px;
border-radius: 9999px;
flex-shrink: 0;
}
.sm-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.sm-name {
font-size: 0.8125rem;
color: #374151;
font-weight: 500;
}
:global(.dark) .sm-name {
color: #e5e7eb;
}
.sm-cat {
font-size: 0.625rem;
color: #9ca3af;
margin-top: 0.125rem;
}
.sm-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.sm-btn {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.625rem;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.sm-btn:hover {
color: #374151;
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .sm-btn {
border-color: rgba(255, 255, 255, 0.08);
}
:global(.dark) .sm-btn:hover {
color: #e5e7eb;
background: rgba(255, 255, 255, 0.05);
}
.sm-btn.primary {
background: #ec4899;
color: white;
border-color: #ec4899;
}
.sm-btn.primary:hover {
filter: brightness(1.1);
background: #ec4899;
}
.sm-btn.danger:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.3);
}
.sm-empty {
padding: 2rem 0;
text-align: center;
color: #9ca3af;
font-size: 0.8125rem;
}
</style>