Initial scaffold: Expo SDK 56, expo-router, expo-sqlite, NativeWind

This commit is contained in:
2026-06-18 16:50:27 +02:00
parent f12febf122
commit 8388f49fd4
24 changed files with 10060 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
import * as SQLite from "expo-sqlite";
import type { Incident, IncidentDraft, IncidentStatus } from "@/types/incident";
const DB_NAME = "sheethappens.db";
let dbInstance: SQLite.SQLiteDatabase | null = null;
async function getDb(): Promise<SQLite.SQLiteDatabase> {
if (dbInstance) return dbInstance;
dbInstance = await SQLite.openDatabaseAsync(DB_NAME);
await migrate(dbInstance);
return dbInstance;
}
async function migrate(db: SQLite.SQLiteDatabase): Promise<void> {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY NOT NULL
);
`);
const row = await db.getFirstAsync<{ version: number }>(
"SELECT MAX(version) as version FROM schema_version"
);
const current = row?.version ?? 0;
if (current < 1) {
await db.execAsync(`
CREATE TABLE IF NOT EXISTS incidents (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL DEFAULT '',
service TEXT NOT NULL DEFAULT '',
symptom TEXT NOT NULL DEFAULT '',
root_cause TEXT NOT NULL DEFAULT '',
fix TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]'
);
CREATE INDEX IF NOT EXISTS idx_incidents_status
ON incidents(status);
CREATE INDEX IF NOT EXISTS idx_incidents_created_at
ON incidents(created_at DESC);
INSERT INTO schema_version (version) VALUES (1);
`);
}
}
// --- row mapper ---
interface IncidentRow {
id: string;
title: string;
service: string;
symptom: string;
root_cause: string;
fix: string;
status: IncidentStatus;
created_at: string;
updated_at: string;
tags: string;
}
function rowToIncident(row: IncidentRow): Incident {
return {
id: row.id,
title: row.title,
service: row.service,
symptom: row.symptom,
rootCause: row.root_cause,
fix: row.fix,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
tags: JSON.parse(row.tags) as string[],
};
}
// --- CRUD ---
export async function createIncident(draft: IncidentDraft): Promise<Incident> {
const db = await getDb();
const id = generateId();
const now = new Date().toISOString();
await db.runAsync(
`INSERT INTO incidents
(id, title, service, symptom, root_cause, fix, status, created_at, updated_at, tags)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
draft.title,
draft.service,
draft.symptom,
draft.rootCause,
draft.fix,
draft.status,
now,
now,
JSON.stringify(draft.tags),
]
);
return {
...draft,
id,
createdAt: now,
updatedAt: now,
};
}
export async function getIncidents(limit = 50): Promise<Incident[]> {
const db = await getDb();
const rows = await db.getAllAsync<IncidentRow>(
"SELECT * FROM incidents ORDER BY created_at DESC LIMIT ?",
[limit]
);
return rows.map(rowToIncident);
}
export async function getIncidentById(id: string): Promise<Incident | null> {
const db = await getDb();
const row = await db.getFirstAsync<IncidentRow>(
"SELECT * FROM incidents WHERE id = ?",
[id]
);
return row ? rowToIncident(row) : null;
}
export async function updateIncident(
id: string,
updates: Partial<IncidentDraft>
): Promise<void> {
const db = await getDb();
const now = new Date().toISOString();
const fields: string[] = [];
const values: (string | null)[] = [];
if (updates.title !== undefined) { fields.push("title = ?"); values.push(updates.title); }
if (updates.service !== undefined) { fields.push("service = ?"); values.push(updates.service); }
if (updates.symptom !== undefined) { fields.push("symptom = ?"); values.push(updates.symptom); }
if (updates.rootCause !== undefined) { fields.push("root_cause = ?"); values.push(updates.rootCause); }
if (updates.fix !== undefined) { fields.push("fix = ?"); values.push(updates.fix); }
if (updates.status !== undefined) { fields.push("status = ?"); values.push(updates.status); }
if (updates.tags !== undefined) { fields.push("tags = ?"); values.push(JSON.stringify(updates.tags)); }
if (fields.length === 0) return;
fields.push("updated_at = ?");
values.push(now);
values.push(id);
await db.runAsync(
`UPDATE incidents SET ${fields.join(", ")} WHERE id = ?`,
values
);
}
export async function deleteIncident(id: string): Promise<void> {
const db = await getDb();
await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]);
}
export async function getDistinctServices(): Promise<string[]> {
const db = await getDb();
const rows = await db.getAllAsync<{ service: string }>(
"SELECT DISTINCT service FROM incidents WHERE service != '' ORDER BY service"
);
return rows.map((r) => r.service);
}
// --- UUID v4 (no external dep) ---
function generateId(): string {
const hex = "0123456789abcdef";
let uuid = "";
for (let i = 0; i < 36; i++) {
if (i === 8 || i === 13 || i === 18 || i === 23) {
uuid += "-";
} else if (i === 14) {
uuid += "4";
} else if (i === 19) {
uuid += hex[(Math.random() * 4) | 8];
} else {
uuid += hex[(Math.random() * 16) | 0];
}
}
return uuid;
}
+75
View File
@@ -0,0 +1,75 @@
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
export interface GiteaConfig {
url: string;
token: string;
owner: string;
repo: string;
}
export interface PushResult {
success: boolean;
url?: string;
error?: string;
}
export async function pushIncidentToGitea(
incident: Incident,
config: GiteaConfig,
markdownTemplate?: string
): Promise<PushResult> {
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
// Check if file already exists to get its SHA (required for updates)
const existingResponse = await fetch(endpoint, {
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
});
let sha: string | undefined;
if (existingResponse.ok) {
const existing = await existingResponse.json() as { sha?: string };
sha = existing.sha;
}
const body: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) body.sha = sha;
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const err = await response.text();
return { success: false, error: `HTTP ${response.status}: ${err}` };
}
const data = await response.json() as { content?: { html_url?: string } };
return {
success: true,
url: data.content?.html_url,
};
} catch (e) {
return {
success: false,
error: e instanceof Error ? e.message : String(e),
};
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { Incident } from "@/types/incident";
const DEFAULT_TEMPLATE = `# [TITRE]
**Date**: [DATE ISO]
**Service**: [SERVICE]
**Status**: [STATUS]
**Tags**: [TAGS]
## Symptom
[SYMPTOM]
## Root Cause
[ROOT_CAUSE]
## Fix Applied
[FIX]
`;
export function renderMarkdown(
incident: Incident,
template: string = DEFAULT_TEMPLATE
): string {
const tagList =
incident.tags.length > 0 ? incident.tags.join(", ") : "—";
return template
.replace("[TITRE]", incident.title || "Untitled")
.replace("[DATE ISO]", incident.createdAt)
.replace("[SERVICE]", incident.service || "—")
.replace("[STATUS]", incident.status)
.replace("[TAGS]", tagList)
.replace("[SYMPTOM]", incident.symptom || "—")
.replace("[ROOT_CAUSE]", incident.rootCause || "—")
.replace("[FIX]", incident.fix || "—");
}
export function incidentToFilename(incident: Incident): string {
const date = incident.createdAt.slice(0, 10);
const slug = incident.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 50);
return `incidents/${date}-${slug || "incident"}.md`;
}
export { DEFAULT_TEMPLATE };