Files
SheetHappens/app/incident/[id].tsx
T
billisdead 9f163d3b76
Release APK / build (push) Has been cancelled
feat: safe area fix, delete from repo, add Mistral provider
- Wrap root layout with SafeAreaProvider, use useSafeAreaInsets in all
  screens so FAB/action bar/scroll content clear Android nav bar
- Add deleteIncidentFromGit() (Gitea/GitHub/GitLab) — detail screen now
  offers Cancel / Local only / Local + Repo when incident was pushed
- Add Mistral as AI provider (OpenAI-compat, api.mistral.ai/v1,
  mistral-small-latest default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:54:02 +02:00

331 lines
9.5 KiB
TypeScript

import { useEffect, useState } from "react";
import {
View,
Text,
ScrollView,
Pressable,
Alert,
Share,
} from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGit, loadGitConfig, deleteIncidentFromGit } from "@/lib/git";
import type { GitConfig } from "@/lib/git";
import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme";
const SECTION_LABEL = {
color: Colors.text2,
fontSize: 11,
fontWeight: "700" as const,
textTransform: "uppercase" as const,
letterSpacing: 0.8,
marginBottom: 6,
marginTop: 20,
} as const;
const SECTION_CONTENT = {
color: Colors.text1,
fontSize: 14,
fontFamily: "monospace",
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
lineHeight: 20,
} as const;
export default function IncidentDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null);
const [pushing, setPushing] = useState(false);
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => {
if (id) {
Promise.all([getIncidentById(id), loadGitConfig()]).then(([inc, config]) => {
setIncident(inc);
setGitConfig(config);
if (inc) {
navigation.setOptions({ title: inc.title || "Incident" });
}
});
}
}, [id]);
async function handleDelete() {
if (!incident) return;
const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null;
const doDelete = async (deleteFromRepo: boolean) => {
if (deleteFromRepo && gitConfig) {
const result = await deleteIncidentFromGit(incident, gitConfig);
if (!result.success) {
Alert.alert("Repo deletion failed", result.error ?? "Unknown error");
return;
}
}
await deleteIncident(incident.id);
router.replace("/");
};
if (canDeleteFromRepo) {
Alert.alert(
"Delete incident?",
"This incident was pushed to the repository. Do you also want to delete the file from the repo?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Local only",
onPress: () => doDelete(false),
},
{
text: "Local + Repo",
style: "destructive",
onPress: () => doDelete(true),
},
]
);
} else {
Alert.alert(
"Delete incident?",
"This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => doDelete(false),
},
]
);
}
}
async function handleResolve() {
if (!incident) return;
await updateIncident(incident.id, { status: "resolved" });
setIncident({ ...incident, status: "resolved" });
}
async function handleGitPush() {
if (!incident) return;
const [config, template] = await Promise.all([
loadGitConfig(),
SecureStore.getItemAsync("md_template"),
]);
if (!config) {
Alert.alert("Git not configured", "Set up a git provider in Settings first.");
return;
}
setPushing(true);
const result = await pushIncidentToGit(incident, config, template ?? undefined);
setPushing(false);
if (result.success) {
const now = new Date().toISOString();
await markIncidentPushed(incident.id);
setIncident((prev) => prev ? { ...prev, gitPushedAt: now } : prev);
Alert.alert("Pushed", result.url ?? "Push successful.");
} else {
Alert.alert("Push failed", result.error ?? "Unknown error");
}
}
async function handleExport() {
if (!incident) return;
const md = renderMarkdown(incident);
try {
await Share.share({ message: md, title: incident.title });
} catch {
await Clipboard.setStringAsync(md);
Alert.alert("Copied", "Markdown copied to clipboard.");
}
}
if (!incident) {
return (
<View
style={{ flex: 1, backgroundColor: Colors.bg, alignItems: "center", justifyContent: "center" }}
>
<Text style={{ color: Colors.textDim }}>Loading</Text>
</View>
);
}
return (
<ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
>
{/* Status badge */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}>
<View
style={{
backgroundColor:
incident.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 4,
}}
>
<Text
style={{
color: incident.status === "open" ? Colors.warning : Colors.success,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
}}
>
{incident.status}
</Text>
</View>
{incident.service ? (
<Text style={{ color: Colors.textDim, fontSize: 12 }}>
{incident.service}
</Text>
) : null}
<Text style={{ color: Colors.textDim, fontSize: 12 }}>
{incident.createdAt.slice(0, 16).replace("T", " ")}
</Text>
</View>
<Text
style={{
color: Colors.text1,
fontSize: 22,
fontWeight: "700",
marginBottom: 4,
}}
>
{incident.title || "Untitled"}
</Text>
{incident.tags.length > 0 && (
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8 }}>
{incident.tags.map((tag) => (
<View
key={tag}
style={{
backgroundColor: Colors.surface2,
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 3,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12 }}>{tag}</Text>
</View>
))}
</View>
)}
<Text style={SECTION_LABEL}>Symptom</Text>
<Text style={SECTION_CONTENT}>{incident.symptom || "—"}</Text>
<Text style={SECTION_LABEL}>Root Cause</Text>
<Text style={SECTION_CONTENT}>{incident.rootCause || "—"}</Text>
<Text style={SECTION_LABEL}>Fix Applied</Text>
<Text style={SECTION_CONTENT}>{incident.fix || "—"}</Text>
{/* Actions */}
<View style={{ gap: 10, marginTop: 28 }}>
{incident.status === "open" && (
<Pressable
onPress={handleResolve}
style={{
backgroundColor: "#88D65620",
borderWidth: 1,
borderColor: Colors.success,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.success, fontWeight: "700", fontSize: 15 }}>
Mark as Resolved
</Text>
</Pressable>
)}
<Pressable
onPress={handleExport}
style={{
backgroundColor: Colors.surface,
borderWidth: 1,
borderColor: Colors.border,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text1, fontWeight: "600", fontSize: 15 }}>
Export Markdown
</Text>
</Pressable>
<Pressable
onPress={handleGitPush}
disabled={pushing}
style={{
backgroundColor: pushing ? Colors.surface2 : Colors.surface,
borderWidth: 1,
borderColor: pushing ? Colors.border : Colors.primary,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text
style={{
color: pushing ? Colors.textDim : Colors.primary,
fontWeight: "600",
fontSize: 15,
}}
>
{pushing ? "Pushing…" : "Push to Git"}
</Text>
</Pressable>
<Pressable
onPress={() => router.push({ pathname: "/new", params: { editId: incident.id } })}
style={{
backgroundColor: Colors.surface,
borderWidth: 1,
borderColor: Colors.border,
borderRadius: 10,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontWeight: "600", fontSize: 15 }}>
Edit
</Text>
</Pressable>
<Pressable
onPress={handleDelete}
style={{
backgroundColor: "#ef444410",
borderWidth: 1,
borderColor: "#ef4444",
borderRadius: 10,
padding: 14,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#ef4444", fontWeight: "600", fontSize: 15 }}>
Delete Incident
</Text>
</Pressable>
</View>
</ScrollView>
);
}