Files
SheetHappens/app/new.tsx
T
billisdead c915d5f2ad
Release APK / build (push) Has been cancelled
feat: delete incident, fix git push, title template, clarify AI settings
- IncidentDetailScreen: add Delete button (confirm dialog) wired to deleteIncident()
- git.ts: fix Gitea POST vs PUT (use PUT when file exists/sha present); add
  pre-flight validation for missing URL/token/owner/repo; add 15s fetch timeout
  with AbortError handling; improve error messages with actionable hints
- [id].tsx: guard URL for Gitea/GitLab before calling pushIncidentToGit
- settings.tsx: move ToggleRow/SegmentRow/Field outside SettingsScreen to fix
  TextInput focus loss on each keystroke (component remount on rerender)
- settings.tsx: add Title Template setting with auto-increment info card
- settings.tsx: add AI base URL + model fields; expand AI section description
- new.tsx: pre-fill title from pref_title_template; increment counter after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:34:41 +02:00

380 lines
11 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
import {
View,
Text,
TextInput,
ScrollView,
Pressable,
KeyboardAvoidingView,
Platform,
Alert,
} from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import * as SecureStore from "expo-secure-store";
import {
createIncident,
updateIncident,
getIncidentById,
getDistinctServices,
} from "@/lib/db";
import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice";
import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = {
backgroundColor: Colors.surface,
borderColor: Colors.border,
borderWidth: 1,
borderRadius: 8,
color: Colors.text1,
padding: 12,
fontSize: 14,
marginBottom: 16,
} as const;
const MONO_FIELD_STYLE = {
...FIELD_STYLE,
fontFamily: "monospace",
minHeight: 80,
textAlignVertical: "top" as const,
} as const;
const LABEL_STYLE = {
color: Colors.text2,
fontSize: 12,
fontWeight: "600" as const,
textTransform: "uppercase" as const,
letterSpacing: 0.5,
} as const;
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
function incrementTemplate(template: string): string {
const match = template.match(/^([\s\S]*?)(\d+)$/);
if (!match) return template;
const [, prefix, numStr] = match;
const next = (parseInt(numStr, 10) + 1).toString().padStart(numStr.length, "0");
return prefix + next;
}
export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation();
const [form, setForm] = useState<IncidentDraft>({
title: "",
service: "",
symptom: "",
rootCause: "",
fix: "",
status: "open",
tags: [],
});
const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false);
const { state: voiceState, transcript, start: startVoice, stop: stopVoice } = useVoice();
const voiceFieldRef = useRef<VoiceField | null>(null);
const [listeningField, setListeningField] = useState<VoiceField | null>(null);
useEffect(() => {
getDistinctServices().then(setKnownServices);
}, []);
useEffect(() => {
if (editId) return;
SecureStore.getItemAsync("pref_title_template").then((tpl) => {
if (tpl) setForm((f) => ({ ...f, title: tpl }));
});
}, [editId]);
useEffect(() => {
if (!editId) return;
navigation.setOptions({ title: "Edit Incident" });
getIncidentById(editId).then((inc) => {
if (!inc) return;
setForm({
title: inc.title,
service: inc.service,
symptom: inc.symptom,
rootCause: inc.rootCause,
fix: inc.fix,
status: inc.status,
tags: inc.tags,
});
});
}, [editId]);
// Live-fill the active voice field as transcript updates
useEffect(() => {
const field = voiceFieldRef.current;
if (transcript && field) {
setForm((f) => ({ ...f, [field]: transcript }));
}
}, [transcript]);
// Clear listening state when recognition ends
useEffect(() => {
if ((voiceState === "idle" || voiceState === "error") && voiceFieldRef.current !== null) {
voiceFieldRef.current = null;
setListeningField(null);
}
}, [voiceState]);
function handleMic(field: VoiceField) {
if (listeningField !== null) {
stopVoice();
return;
}
voiceFieldRef.current = field;
setListeningField(field);
startVoice("fr-FR");
}
function set(key: keyof IncidentDraft) {
return (value: string) => setForm((f) => ({ ...f, [key]: value }));
}
function handleTagInputEnd() {
const trimmed = tagInput.trim();
if (trimmed && !form.tags.includes(trimmed)) {
setForm((f) => ({ ...f, tags: [...f.tags, trimmed] }));
}
setTagInput("");
}
function removeTag(tag: string) {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
}
async function handleSave() {
if (!form.title.trim()) {
Alert.alert("Required", "Title is required.");
return;
}
setSaving(true);
try {
if (editId) {
await updateIncident(editId, form);
} else {
await createIncident(form);
const tpl = await SecureStore.getItemAsync("pref_title_template");
if (tpl) {
await SecureStore.setItemAsync("pref_title_template", incrementTemplate(tpl));
}
}
router.back();
} catch {
Alert.alert("Error", "Failed to save incident.");
} finally {
setSaving(false);
}
}
const filteredServices =
serviceFocused && form.service.length > 0
? knownServices.filter(
(s) =>
s.toLowerCase().includes(form.service.toLowerCase()) &&
s !== form.service
)
: [];
function micButton(field: VoiceField) {
const active = listeningField === field;
return (
<Pressable
onPress={() => handleMic(field)}
style={{
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 4,
backgroundColor: active ? "#ef444420" : Colors.surface2,
borderWidth: 1,
borderColor: active ? Colors.danger : Colors.border,
}}
>
<Text
style={{
color: active ? Colors.danger : Colors.text2,
fontSize: 11,
fontWeight: "700",
}}
>
{active ? "STOP" : "MIC"}
</Text>
</Pressable>
);
}
function labelRow(label: string, field: VoiceField) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 6,
}}
>
<Text style={LABEL_STYLE}>{label}</Text>
{micButton(field)}
</View>
);
}
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: Colors.bg }}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
keyboardShouldPersistTaps="handled"
>
{labelRow("Title *", "title")}
<TextInput
style={FIELD_STYLE}
value={form.title}
onChangeText={set("title")}
placeholder="e.g. Ghost blog down after k3s node drain"
placeholderTextColor={Colors.textDim}
returnKeyType="next"
/>
{labelRow("Service", "service")}
<TextInput
style={FIELD_STYLE}
value={form.service}
onChangeText={set("service")}
onFocus={() => setServiceFocused(true)}
onBlur={() => setTimeout(() => setServiceFocused(false), 150)}
placeholder="e.g. ghost-blog, k3s-cluster, haproxy"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
returnKeyType="next"
/>
{filteredServices.length > 0 && (
<View
style={{
backgroundColor: Colors.surface2,
borderRadius: 8,
borderWidth: 1,
borderColor: Colors.border,
marginTop: -12,
marginBottom: 16,
overflow: "hidden",
}}
>
{filteredServices.slice(0, 5).map((s, i) => (
<Pressable
key={s}
onPress={() => {
setForm((f) => ({ ...f, service: s }));
setServiceFocused(false);
}}
style={{
padding: 12,
borderTopWidth: i === 0 ? 0 : 1,
borderTopColor: Colors.border,
}}
>
<Text style={{ color: Colors.text1, fontSize: 14 }}>{s}</Text>
</Pressable>
))}
</View>
)}
{labelRow("Symptom", "symptom")}
<TextInput
style={MONO_FIELD_STYLE}
value={form.symptom}
onChangeText={set("symptom")}
placeholder="What was observed?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="next"
/>
{labelRow("Root Cause", "rootCause")}
<TextInput
style={MONO_FIELD_STYLE}
value={form.rootCause}
onChangeText={set("rootCause")}
placeholder="Why did it happen?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="next"
/>
{labelRow("Fix Applied", "fix")}
<TextInput
style={MONO_FIELD_STYLE}
value={form.fix}
onChangeText={set("fix")}
placeholder="What did you do to fix it?"
placeholderTextColor={Colors.textDim}
multiline
returnKeyType="done"
/>
<Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Tags</Text>
<TextInput
style={FIELD_STYLE}
value={tagInput}
onChangeText={setTagInput}
onSubmitEditing={handleTagInputEnd}
onBlur={handleTagInputEnd}
placeholder="Type tag and press Enter"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
returnKeyType="done"
blurOnSubmit={false}
/>
{form.tags.length > 0 && (
<View
style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginBottom: 16 }}
>
{form.tags.map((tag) => (
<Pressable
key={tag}
onPress={() => removeTag(tag)}
style={{
backgroundColor: Colors.surface2,
borderRadius: 4,
paddingHorizontal: 10,
paddingVertical: 4,
flexDirection: "row",
alignItems: "center",
gap: 6,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13 }}>{tag}</Text>
<Text style={{ color: Colors.textDim, fontSize: 12 }}>x</Text>
</Pressable>
))}
</View>
)}
<Pressable
onPress={handleSave}
disabled={saving}
style={{
backgroundColor: saving ? Colors.primaryDim : Colors.primary,
borderRadius: 10,
padding: 16,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
{saving ? "Saving…" : editId ? "Save Changes" : "Save Incident"}
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}