feat: git push status indicator + multi-select bulk push
Release APK / build (push) Has been cancelled

- 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>
This commit is contained in:
2026-06-23 18:11:23 +02:00
parent 1437e99cbb
commit 947ae5917c
5 changed files with 392 additions and 139 deletions
+12 -52
View File
@@ -10,10 +10,9 @@ import {
import { useLocalSearchParams, router, useNavigation } from "expo-router";
import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident, deleteIncident } from "@/lib/db";
import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGit } from "@/lib/git";
import type { GitProvider } from "@/lib/git";
import { pushIncidentToGit, loadGitConfig } from "@/lib/git";
import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme";
@@ -81,60 +80,21 @@ export default function IncidentDetailScreen() {
async function handleGitPush() {
if (!incident) return;
const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] =
await Promise.all([
SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("md_template"),
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
const p = (provider ?? "gitea") as GitProvider;
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = giteaUrl ?? undefined;
token = giteaToken;
owner = giteaOwner;
repo = giteaRepo;
} else if (p === "github") {
token = githubToken;
owner = githubOwner;
repo = githubRepo;
} else {
url = gitlabUrl ?? undefined;
token = gitlabToken;
owner = gitlabOwner;
repo = gitlabRepo;
}
const needsUrl = p === "gitea" || p === "gitlab";
if (!token || !owner || !repo || (needsUrl && !url)) {
Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`);
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,
{ provider: p, url, token, owner, repo },
template ?? undefined
);
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");
+321 -86
View File
@@ -5,16 +5,32 @@ import {
FlatList,
Pressable,
ActivityIndicator,
Alert,
} from "react-native";
import { router, useFocusEffect } from "expo-router";
import { getIncidents } from "@/lib/db";
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);
@@ -28,24 +44,99 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
loadIncidents();
load();
}, [])
);
async function loadIncidents() {
async function load() {
setLoading(true);
const data = await getIncidents(10);
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 }}
/>
<ActivityIndicator color={Colors.primary} style={{ marginTop: 48 }} />
) : (
<FlatList
data={incidents}
@@ -63,98 +154,242 @@ export default function HomeScreen() {
No incidents. Press + to log one.
</Text>
}
renderItem={({ item }) => (
<Pressable
onPress={() => router.push(`/incident/${item.id}`)}
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 16,
marginBottom: 10,
borderLeftWidth: 3,
borderLeftColor:
item.status === "open" ? Colors.warning : Colors.success,
}}
>
<View
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",
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={{
backgroundColor:
item.status === "open"
? "#f59e0b20"
: "#88D65620",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
marginLeft: 8,
}}
>
<Text
<View style={{ flex: 1 }}>
<View
style={{
color:
item.status === "open"
? Colors.warning
: Colors.success,
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 4,
}}
>
{item.status}
<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>
</View>
<Text
style={{ color: Colors.text2, fontSize: 12 }}
numberOfLines={1}
>
{item.service || "—"} · {item.createdAt.slice(0, 10)}
</Text>
</Pressable>
)}
{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 */}
<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 }}
{/* 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>
</Pressable>
<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>
);
}
+18
View File
@@ -45,6 +45,13 @@ async function migrate(db: SQLite.SQLiteDatabase): Promise<void> {
INSERT INTO schema_version (version) VALUES (1);
`);
}
if (current < 2) {
await db.execAsync(`
ALTER TABLE incidents ADD COLUMN git_pushed_at TEXT;
INSERT INTO schema_version (version) VALUES (2);
`);
}
}
// --- row mapper ---
@@ -60,6 +67,7 @@ interface IncidentRow {
created_at: string;
updated_at: string;
tags: string;
git_pushed_at: string | null;
}
function rowToIncident(row: IncidentRow): Incident {
@@ -74,6 +82,7 @@ function rowToIncident(row: IncidentRow): Incident {
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: JSON.parse(row.tags) as string[],
gitPushedAt: row.git_pushed_at ?? null,
};
}
@@ -107,6 +116,7 @@ export async function createIncident(draft: IncidentDraft): Promise<Incident> {
id,
createdAt: now,
updatedAt: now,
gitPushedAt: null,
};
}
@@ -163,6 +173,14 @@ export async function deleteIncident(id: string): Promise<void> {
await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]);
}
export async function markIncidentPushed(id: string): Promise<void> {
const db = await getDb();
await db.runAsync(
"UPDATE incidents SET git_pushed_at = ? WHERE id = ?",
[new Date().toISOString(), id]
);
}
export async function getMaxTitleNumber(prefix: string): Promise<number> {
const db = await getDb();
const rows = await db.getAllAsync<{ title: string }>(
+39
View File
@@ -1,3 +1,4 @@
import * as SecureStore from "expo-secure-store";
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
@@ -19,6 +20,44 @@ export interface PushResult {
const TIMEOUT_MS = 15_000;
export async function loadGitConfig(): Promise<GitConfig | null> {
const [provider, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
await Promise.all([
SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
const p = (provider ?? "gitea") as GitProvider;
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = gu ?? undefined;
token = gt; owner = go; repo = gr;
} else if (p === "github") {
token = ghu; owner = ghown; repo = ghrepo;
} else {
url = glu ?? undefined;
token = glt; owner = glo; repo = glr;
}
const needsUrl = p === "gitea" || p === "gitlab";
if (!token || !owner || !repo || (needsUrl && !url)) return null;
return { provider: p, url, token, owner, repo };
}
function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
+2 -1
View File
@@ -11,6 +11,7 @@ export interface Incident {
createdAt: string;
updatedAt: string;
tags: string[];
gitPushedAt: string | null;
}
export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt">;
export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt" | "gitPushedAt">;