Files
SheetHappens/app/index.tsx
T
billisdead 947ae5917c
Release APK / build (push) Has been cancelled
feat: git push status indicator + multi-select bulk push
- DB migration v2: git_pushed_at column on incidents
- markIncidentPushed(id): stamps the push timestamp
- loadGitConfig(): shared helper in lib/git.ts, removes duplication
- Home screen: colored dot per incident (red=never pushed,
  orange=dirty/modified after push, green=up to date);
  dot visible only when git is configured
- Home screen: long-press enters selection mode, tap toggles;
  action bar with Select unpushed / Select all shortcuts +
  bulk push with sequential progress counter
- [id].tsx: marks incident pushed after successful push,
  updates local state without reload

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

396 lines
12 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
View,
Text,
FlatList,
Pressable,
ActivityIndicator,
Alert,
} from "react-native";
import { router, useFocusEffect } from "expo-router";
import * as SecureStore from "expo-secure-store";
import { getIncidents, markIncidentPushed } from "@/lib/db";
import { pushIncidentToGit, loadGitConfig } from "@/lib/git";
import type { GitConfig } from "@/lib/git";
import { useSettings } from "@/hooks/useSettings";
import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme";
function gitDotColor(inc: Incident): string {
if (!inc.gitPushedAt) return Colors.danger;
if (inc.gitPushedAt < inc.updatedAt) return Colors.warning;
return Colors.success;
}
export default function HomeScreen() {
const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true);
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
const [mdTemplate, setMdTemplate] = useState<string | undefined>(undefined);
const [selectionMode, setSelectionMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkPushing, setBulkPushing] = useState(false);
const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 });
const { settings, ready } = useSettings();
const didRedirect = useRef(false);
useEffect(() => {
if (!ready || didRedirect.current) return;
if (settings.homeView === "capture") {
didRedirect.current = true;
router.push("/new");
}
}, [ready, settings.homeView]);
useFocusEffect(
useCallback(() => {
load();
}, [])
);
async function load() {
setLoading(true);
const [data, config, tpl] = await Promise.all([
getIncidents(100),
loadGitConfig(),
SecureStore.getItemAsync("md_template"),
]);
setIncidents(data);
setGitConfig(config);
setMdTemplate(tpl ?? undefined);
setLoading(false);
}
function enterSelectionMode(id: string) {
setSelectionMode(true);
setSelected(new Set([id]));
}
function toggleSelection(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function exitSelectionMode() {
setSelectionMode(false);
setSelected(new Set());
}
function selectAllUnpushed() {
const ids = incidents
.filter((i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt)
.map((i) => i.id);
setSelected(new Set(ids));
}
const unpushedCount = incidents.filter(
(i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt
).length;
async function handleBulkPush() {
if (!gitConfig) {
Alert.alert("Git not configured", "Set up a git provider in Settings first.");
return;
}
const toPush = incidents.filter((i) => selected.has(i.id));
if (toPush.length === 0) return;
setBulkPushing(true);
setBulkProgress({ done: 0, total: toPush.length });
let ok = 0;
let fail = 0;
const now = new Date().toISOString();
for (let i = 0; i < toPush.length; i++) {
const inc = toPush[i];
const result = await pushIncidentToGit(inc, gitConfig, mdTemplate);
if (result.success) {
await markIncidentPushed(inc.id);
setIncidents((prev) =>
prev.map((x) => (x.id === inc.id ? { ...x, gitPushedAt: now } : x))
);
ok++;
} else {
fail++;
}
setBulkProgress({ done: i + 1, total: toPush.length });
}
setBulkPushing(false);
exitSelectionMode();
if (fail === 0) {
Alert.alert("Done", `${ok} incident${ok > 1 ? "s" : ""} pushed.`);
} else {
Alert.alert("Partial", `${ok} pushed, ${fail} failed.`);
}
}
const gitConfigured = gitConfig !== null;
return (
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
{loading ? (
<ActivityIndicator color={Colors.primary} style={{ marginTop: 48 }} />
) : (
<FlatList
data={incidents}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16, paddingBottom: 96 }}
ListEmptyComponent={
<Text
style={{
color: Colors.textDim,
textAlign: "center",
marginTop: 64,
fontSize: 15,
}}
>
No incidents. Press + to log one.
</Text>
}
renderItem={({ item }) => {
const isSelected = selected.has(item.id);
return (
<Pressable
onPress={() =>
selectionMode
? toggleSelection(item.id)
: router.push(`/incident/${item.id}`)
}
onLongPress={() => !selectionMode && enterSelectionMode(item.id)}
style={{
backgroundColor: isSelected ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 16,
marginBottom: 10,
borderLeftWidth: 3,
borderLeftColor:
item.status === "open" ? Colors.warning : Colors.success,
flexDirection: "row",
alignItems: "center",
}}
>
<View style={{ flex: 1 }}>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 4,
}}
>
<Text
style={{
color: Colors.text1,
fontSize: 15,
fontWeight: "600",
flex: 1,
}}
numberOfLines={1}
>
{item.title || "Untitled"}
</Text>
<View style={{ flexDirection: "row", alignItems: "center", marginLeft: 8, gap: 6 }}>
{gitConfigured && !selectionMode && (
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: gitDotColor(item),
}}
/>
)}
<View
style={{
backgroundColor:
item.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Text
style={{
color:
item.status === "open"
? Colors.warning
: Colors.success,
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
}}
>
{item.status}
</Text>
</View>
</View>
</View>
<Text
style={{ color: Colors.text2, fontSize: 12 }}
numberOfLines={1}
>
{item.service || "—"} · {item.createdAt.slice(0, 10)}
</Text>
</View>
{selectionMode && (
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: isSelected ? Colors.primary : Colors.border,
backgroundColor: isSelected ? Colors.primary : "transparent",
alignItems: "center",
justifyContent: "center",
marginLeft: 14,
}}
>
{isSelected && (
<Text
style={{ color: Colors.bg, fontSize: 12, fontWeight: "700" }}
>
</Text>
)}
</View>
)}
</Pressable>
);
}}
/>
)}
{/* FAB — hidden in selection mode */}
{!selectionMode && (
<Pressable
onPress={() => router.push("/new")}
style={{
position: "absolute",
bottom: 24,
right: 24,
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: Colors.primary,
alignItems: "center",
justifyContent: "center",
elevation: 6,
}}
>
<Text style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}>+</Text>
</Pressable>
)}
{/* Selection mode action bar */}
{selectionMode && (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: Colors.surface,
borderTopWidth: 1,
borderTopColor: Colors.border,
padding: 12,
gap: 10,
}}
>
{/* Quick-select shortcuts */}
<View style={{ flexDirection: "row", gap: 8 }}>
{gitConfigured && unpushedCount > 0 && (
<Pressable
onPress={selectAllUnpushed}
style={{
flex: 1,
borderRadius: 6,
borderWidth: 1,
borderColor: Colors.border,
padding: 8,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, fontWeight: "600" }}>
Select unpushed ({unpushedCount})
</Text>
</Pressable>
)}
<Pressable
onPress={() => setSelected(new Set(incidents.map((i) => i.id)))}
style={{
flex: 1,
borderRadius: 6,
borderWidth: 1,
borderColor: Colors.border,
padding: 8,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, fontWeight: "600" }}>
Select all ({incidents.length})
</Text>
</Pressable>
</View>
{/* Main actions */}
<View style={{ flexDirection: "row", gap: 10 }}>
<Pressable
onPress={exitSelectionMode}
style={{
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: Colors.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontSize: 15, fontWeight: "600" }}>
Cancel
</Text>
</Pressable>
<Pressable
onPress={handleBulkPush}
disabled={selected.size === 0 || bulkPushing || !gitConfigured}
style={{
flex: 2,
borderRadius: 8,
backgroundColor:
selected.size === 0 || !gitConfigured
? Colors.surface2
: Colors.primary,
padding: 14,
alignItems: "center",
}}
>
<Text
style={{
color:
selected.size === 0 || !gitConfigured
? Colors.textDim
: Colors.bg,
fontSize: 15,
fontWeight: "700",
}}
>
{bulkPushing
? `Pushing ${bulkProgress.done}/${bulkProgress.total}`
: `Push ${selected.size > 0 ? selected.size : ""} selected`}
</Text>
</Pressable>
</View>
</View>
)}
</View>
);
}