Files
SheetHappens/app/new.tsx
T
billisdead 1437e99cbb
Release APK / build (push) Has been cancelled
fix: title increment based on DB max, not stored template only
After each incident save, query existing titles with the same prefix
to find the real max number, then use max(dbMax, tplNum) + 1.
Prevents duplicate counters when incidents were created before the
template was configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:26:10 +02:00

428 lines
12 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,
getMaxTitleNumber,
} from "@/lib/db";
import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice";
import { callAI } from "@/lib/ai";
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";
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 [suggesting, setSuggesting] = useState(false);
const [suggestError, setSuggestError] = useState("");
const [aiEnabled, setAiEnabled] = 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);
SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true"));
}, []);
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 handleSuggest() {
setSuggesting(true);
setSuggestError("");
try {
const result = await callAI(form.title, form.symptom);
setForm((f) => ({ ...f, rootCause: result.rootCause, fix: result.fix }));
} catch (e) {
setSuggestError(e instanceof Error ? e.message : "AI call failed");
} finally {
setSuggesting(false);
}
}
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) {
const match = tpl.match(/^([\s\S]*?)(\d+)$/);
if (match) {
const [, prefix, numStr] = match;
const dbMax = await getMaxTitleNumber(prefix);
const tplNum = parseInt(numStr, 10);
const next = (Math.max(dbMax, tplNum) + 1).toString().padStart(numStr.length, "0");
await SecureStore.setItemAsync("pref_title_template", prefix + next);
}
}
}
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"
/>
{aiEnabled && form.title.trim() && form.symptom.trim() && (
<View style={{ marginBottom: 16 }}>
<Pressable
onPress={handleSuggest}
disabled={suggesting}
style={{
backgroundColor: suggesting ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor: Colors.primary,
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Text style={{ color: Colors.primary, fontSize: 14, fontWeight: "700" }}>
{suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"}
</Text>
</Pressable>
{suggestError !== "" && (
<Text style={{ color: Colors.danger, fontSize: 12, marginTop: 6 }}>
{suggestError}
</Text>
)}
</View>
)}
{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>
);
}