chore: initial commit of Mawari 1.0.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
Samuel Zielke 23 hours ago
commit 21edee59aa

32
.gitignore vendored

@ -0,0 +1,32 @@
# macOS
.DS_Store
# Xcode / Build
build/
DerivedData/
xcuserdata/
*.xcuserstate
*.xcscmblueprint
*.moved-aside
build-dmg.log
*.log
# SwiftPM
.build/
.swiftpm/
# Aus project.yml generiert (xcodegen)
Mawari.xcodeproj/
App/Info.plist
# Schlüssel und Exporte – gehören NIE ins Repo
*.key
*.p12
*.pem
mawari-sparkle.key
# Echte Daten (falls mal zum Testen in den Projektordner kopiert)
daten.json
leben-und-dienst*.json
mawari-sicherung-*.json
Sicherungen/

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

Binary file not shown.

@ -0,0 +1,93 @@
import SwiftUI
struct EntrySheet: View {
@EnvironmentObject var store: Store
@EnvironmentObject var ui: UIState
@Environment(\.dismiss) private var dismiss
@State private var entry: Entry
@State private var date: Date
@State private var showAll = false
init(entry: Entry) {
_entry = State(initialValue: entry)
_date = State(initialValue: Day.date(entry.date) ?? Date())
}
private var task: DutyTask? { store.task(entry.task) }
private var showPartner: Bool { task?.withPartner == true || !entry.partner.isEmpty }
private func people(for taskID: String?, keep: String) -> [Person] {
store.personsSorted.filter { showAll || taskID == nil || $0.may(taskID!) || $0.id == keep }
}
private var warnings: [String] {
var w: [String] = []
if let t = task, let p = store.person(entry.person), !p.may(t.id) { w.append("\(p.name) ist für „\(t.name)“ nicht freigegeben.") }
if let pa = store.person(entry.partner), !pa.may("partner") { w.append("\(pa.name) ist nicht als Partner freigegeben.") }
if !entry.partner.isEmpty, entry.partner == entry.person { w.append("Person und Partner sind gleich.") }
return w
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(entry.isNew ? "Neuer Eintrag" : "Eintrag bearbeiten").font(.title3.weight(.semibold))
Form {
DatePicker("Datum", selection: $date, displayedComponents: .date)
.datePickerStyle(.field)
Picker("Aufgabe", selection: $entry.task) {
Text("Aufgabe wählen …").tag("")
ForEach(TaskGroup.allCases) { g in
Section(g.shortTitle) {
ForEach(store.db.tasks.filter { $0.group == g && !$0.partnerColumn }) { Text($0.name).tag($0.id) }
}
}
}
Picker("Person", selection: $entry.person) {
Text("Person wählen …").tag("")
ForEach(people(for: task?.id, keep: entry.person)) { Text($0.name).tag($0.id) }
}
Toggle("Alle Personen anzeigen (auch ohne Freigabe)", isOn: $showAll)
if showPartner {
Picker("Partner", selection: $entry.partner) {
Text("– kein Partner –").tag("")
ForEach(people(for: "partner", keep: entry.partner)) { Text($0.name).tag($0.id) }
}
}
TextField("Thema / Notiz", text: $entry.note, prompt: Text("optional"))
}
.formStyle(.columns)
ForEach(warnings, id: \.self) { w in
Label(w, systemImage: "exclamationmark.triangle").foregroundStyle(Theme.kohaku).font(.callout)
}
HStack {
if !entry.isNew {
Button("Löschen", role: .destructive) { store.delete(entry: entry.id); dismiss() }
}
Spacer()
Button("Abbrechen", role: .cancel) { dismiss() }.keyboardShortcut(.cancelAction)
Button(entry.isNew ? "Eintragen" : "Sichern", action: save)
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.disabled(entry.task.isEmpty || entry.person.isEmpty)
}
}
.padding(22)
.frame(width: 460)
}
private func save() {
var e = entry
e.date = Day.string(date)
if !(task?.withPartner ?? false) && e.partner.isEmpty { e.partner = "" }
let wasNew = e.isNew
if wasNew { e.id = newID("e") }
e.note = e.note.trimmingCharacters(in: .whitespaces)
store.save(entry: e)
dismiss()
ui.show(wasNew ? "Eingetragen: \(store.task(e.task)?.name ?? "") · \(store.name(e.person))" : "Eintrag geändert.")
}
}

@ -0,0 +1,206 @@
import AppKit
import UniformTypeIdentifiers
/// Export für Numbers (.xlsx), Sicherung speichern und laden.
/// Eine echte .numbers-Datei lässt sich nicht erzeugen (Apple-eigenes Format);
/// die .xlsx öffnet Numbers direkt und wird nach dem Speichern dort geöffnet.
@MainActor
enum Exporter {
static func exportXlsx(store: Store, ui: UIState) {
let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: "xlsx") ?? .data]
panel.nameFieldStringValue = "Leben-und-Dienst-\(Day.today).xlsx"
panel.message = "Der aktuelle Stand wird als Tabelle gespeichert und in Numbers geöffnet."
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
try XLSXWriter.workbook(sheets: sheets(store: store)).write(to: url, options: .atomic)
openInNumbers(url)
ui.show("Export gespeichert.")
} catch {
ui.show("Export fehlgeschlagen: \(error.localizedDescription)")
}
}
static func saveBackup(store: Store, ui: UIState) {
let panel = NSSavePanel()
panel.allowedContentTypes = [.json]
panel.nameFieldStringValue = "mawari-sicherung-\(Day.today).json"
guard panel.runModal() == .OK, let url = panel.url else { return }
do { try store.encoded().write(to: url, options: .atomic); ui.show("Sicherung gespeichert.") }
catch { ui.show("Sicherung fehlgeschlagen: \(error.localizedDescription)") }
}
static func importBackup(store: Store, ui: UIState) {
let panel = NSOpenPanel()
panel.allowedContentTypes = [.json]
panel.message = "Sicherung oder Datei aus dem HTML-Tool (leben-und-dienst.json) wählen. Der aktuelle Stand wird dabei ersetzt (eine Kopie bleibt im Datenordner)."
guard panel.runModal() == .OK, let url = panel.url else { return }
if !store.db.persons.isEmpty || !store.db.entries.isEmpty {
let alert = NSAlert()
alert.messageText = "Aktuelle Daten ersetzen?"
alert.informativeText = "Alle Personen und Einträge werden durch die Datei ersetzt. Der bisherige Stand wird vorher im Datenordner gesichert."
alert.addButton(withTitle: "Ersetzen")
alert.addButton(withTitle: "Abbrechen")
guard alert.runModal() == .alertFirstButtonReturn else { return }
}
do {
try store.importFile(url)
ui.tab = .overview
ui.show("Geladen: \(store.db.persons.count) Personen, \(store.db.entries.count) Einträge.")
} catch {
ui.show("Datei konnte nicht gelesen werden.")
}
}
private static func openInNumbers(_ url: URL) {
if let numbers = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.iWork.Numbers") {
NSWorkspace.shared.open([url], withApplicationAt: numbers, configuration: NSWorkspace.OpenConfiguration())
} else {
NSWorkspace.shared.open(url)
}
}
// MARK: Inhalt
private static func sheets(store: Store) -> [XLSXWriter.Sheet] {
typealias C = XLSXWriter.Cell
var ov: [[C]] = [
[.text("Leben und Dienst – Übersicht", .title)],
[.text("Stand \(Day.display(Day.today)) · Datum = letzte Einteilung · nie = freigegeben, aber noch nie eingeteilt · – = nicht freigegeben")],
[],
]
var maxCols = 2
for group in TaskGroup.allCases {
let d = UserDefaults.standard
let sort = OverviewSort(column: d.string(forKey: "sort.\(group.rawValue).column") ?? "name",
ascending: d.object(forKey: "sort.\(group.rawValue).ascending") as? Bool ?? true)
let (tasks, rows) = overviewRows(store: store, group: group, sort: sort)
maxCols = max(maxCols, tasks.count + 2)
let band: XLSXWriter.Style = group == .diener ? .bandDiener : .bandSchulung
ov.append([.text(group.title, band)] + Array(repeating: .text(" ", band), count: tasks.count + 1))
ov.append([.text("Name", .header), .text("Zuletzt (irgendeine)", .header)] + tasks.map { .text($0.name, .header) })
for r in rows {
ov.append([.text(r.person.name), r.any.map { .date($0) } ?? .text("nie", .center)] + r.cells.map {
switch $0 {
case .notAllowed: return .text("–", .center)
case .never: return .text("nie", .center)
case .date(let s): return .date(s)
}
})
}
ov.append([])
}
let hist: [[C]] = [["Datum", "Aufgabe", "Person", "Partner", "Thema / Notiz"].map { .text($0, .header) }]
+ store.db.entries.sorted { $0.date > $1.date }.map { e in
[.date(e.date), .text(store.task(e.task)?.name ?? e.task), .text(store.name(e.person)),
.text(e.partner.isEmpty ? "" : store.name(e.partner)), .text(e.note)]
}
let pers: [[C]] = [(["Name", "Bruder/Schwester", "Notiz"] + store.db.tasks.map(\.name)).map { .text($0, .header) }]
+ store.personsSorted.map { p in
[.text(p.name), .text(p.gender), .text(p.note)] + store.db.tasks.map { p.may($0.id) ? .text("x", .center) : .text("") }
}
return [
.init(name: "Übersicht", rows: ov, widths: [26, 13] + Array(repeating: 14, count: maxCols - 2)),
.init(name: "Verlauf", rows: hist, widths: [11, 26, 24, 24, 40]),
.init(name: "Personen", rows: pers, widths: [24, 14, 24] + store.db.tasks.map { _ in 9 }),
]
}
}
/// Minimaler .xlsx-Schreiber (ZIP ohne Kompression), ohne Fremdbibliothek.
enum XLSXWriter {
enum Style: Int { case normal = 0, date = 1, header = 2, title = 3, bandDiener = 4, bandSchulung = 5, center = 6 }
enum Cell { case text(String, Style = .normal), date(String) }
struct Sheet { let name: String; let rows: [[Cell]]; let widths: [Double] }
static func workbook(sheets: [Sheet]) -> Data {
var files: [(String, String)] = [
("[Content_Types].xml", #"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>"#
+ sheets.indices.map { #"<Override PartName="/xl/worksheets/sheet\#($0 + 1).xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>"# }.joined() + "</Types>"),
("_rels/.rels", #"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>"#),
("xl/workbook.xml", #"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>"#
+ sheets.enumerated().map { #"<sheet name="\#(esc($1.name))" sheetId="\#($0 + 1)" r:id="rId\#($0 + 1)"/>"# }.joined() + "</sheets></workbook>"),
("xl/_rels/workbook.xml.rels", #"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#
+ sheets.indices.map { #"<Relationship Id="rId\#($0 + 1)" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet\#($0 + 1).xml"/>"# }.joined()
+ #"<Relationship Id="rId\#(sheets.count + 1)" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#),
("xl/styles.xml", #"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><numFmts count="1"><numFmt numFmtId="164" formatCode="dd.mm.yy"/></numFmts><fonts count="4"><font><sz val="12"/><name val="Helvetica Neue"/></font><font><b/><sz val="12"/><name val="Helvetica Neue"/></font><font><b/><sz val="15"/><name val="Helvetica Neue"/></font><font><b/><sz val="12"/><color rgb="FFFFFFFF"/><name val="Helvetica Neue"/></font></fonts><fills count="5"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF2EEE6"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FF2F6B4F"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FF7A5230"/></patternFill></fill></fills><borders count="1"><border/></borders><cellStyleXfs count="1"><xf/></cellStyleXfs><cellXfs count="7"><xf/><xf numFmtId="164" applyNumberFormat="1" applyAlignment="1"><alignment horizontal="center"/></xf><xf fontId="1" fillId="2" applyFont="1" applyFill="1" applyAlignment="1"><alignment wrapText="1" vertical="bottom"/></xf><xf fontId="2" applyFont="1"/><xf fontId="3" fillId="3" applyFont="1" applyFill="1"/><xf fontId="3" fillId="4" applyFont="1" applyFill="1"/><xf applyAlignment="1"><alignment horizontal="center"/></xf></cellXfs></styleSheet>"#),
]
for (i, s) in sheets.enumerated() { files.append(("xl/worksheets/sheet\(i + 1).xml", sheetXML(s))) }
return zip(files.map { ($0.0, Data($0.1.utf8)) })
}
private static func sheetXML(_ s: Sheet) -> String {
let cols = s.widths.enumerated().map { #"<col min="\#($0 + 1)" max="\#($0 + 1)" width="\#($1)" customWidth="1"/>"# }.joined()
var out = #"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><cols>\#(cols)</cols><sheetData>"#
for (r, row) in s.rows.enumerated() {
out += #"<row r="\#(r + 1)">"#
for (c, cell) in row.enumerated() {
let ref = column(c) + "\(r + 1)"
switch cell {
case .text(let t, let st):
guard !t.isEmpty else { continue }
out += #"<c r="\#(ref)" t="inlineStr"\#(st == .normal ? "" : #" s="\#(st.rawValue)""#)><is><t xml:space="preserve">\#(esc(t))</t></is></c>"#
case .date(let d):
out += #"<c r="\#(ref)" s="1"><v>\#(serial(d))</v></c>"#
}
}
out += "</row>"
}
return out + "</sheetData></worksheet>"
}
private static func serial(_ iso: String) -> Int {
let p = iso.split(separator: "-").compactMap { Int($0) }
guard p.count == 3 else { return 0 }
var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(identifier: "UTC")!
let d = cal.date(from: DateComponents(year: p[0], month: p[1], day: p[2]))!
let base = cal.date(from: DateComponents(year: 1899, month: 12, day: 30))!
return cal.dateComponents([.day], from: base, to: d).day ?? 0
}
private static func column(_ i: Int) -> String {
var n = i + 1, s = ""
while n > 0 { let m = (n - 1) % 26; s = String(UnicodeScalar(65 + m)!) + s; n = (n - 1) / 26 }
return s
}
private static func esc(_ s: String) -> String {
s.replacingOccurrences(of: "&", with: "&amp;").replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;").replacingOccurrences(of: "\"", with: "&quot;")
}
// MARK: ZIP (stored)
private static let crcTable: [UInt32] = (0..<256).map { n -> UInt32 in
var c = UInt32(n)
for _ in 0..<8 { c = c & 1 != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1 }
return c
}
private static func crc32(_ d: Data) -> UInt32 {
var c: UInt32 = 0xFFFFFFFF
for b in d { c = crcTable[Int((c ^ UInt32(b)) & 0xFF)] ^ (c >> 8) }
return c ^ 0xFFFFFFFF
}
private static func zip(_ files: [(String, Data)]) -> Data {
var out = Data(), central = Data()
func u16(_ v: Int, _ d: inout Data) { withUnsafeBytes(of: UInt16(v).littleEndian) { d.append(contentsOf: $0) } }
func u32(_ v: UInt32, _ d: inout Data) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
for (name, body) in files {
let n = Data(name.utf8), crc = crc32(body), offset = UInt32(out.count)
u32(0x04034b50, &out); u16(20, &out); u16(0x0800, &out); u16(0, &out); u16(0, &out); u16(0, &out)
u32(crc, &out); u32(UInt32(body.count), &out); u32(UInt32(body.count), &out); u16(n.count, &out); u16(0, &out)
out.append(n); out.append(body)
u32(0x02014b50, &central); u16(20, &central); u16(20, &central); u16(0x0800, &central); u16(0, &central)
u16(0, &central); u16(0, &central); u32(crc, &central); u32(UInt32(body.count), &central); u32(UInt32(body.count), &central)
u16(n.count, &central); u16(0, &central); u16(0, &central); u16(0, &central); u16(0, &central); u32(0, &central)
u32(offset, &central); central.append(n)
}
let cdOffset = UInt32(out.count)
out.append(central)
u32(0x06054b50, &out); u16(0, &out); u16(0, &out); u16(files.count, &out); u16(files.count, &out)
u32(UInt32(central.count), &out); u32(cdOffset, &out); u16(0, &out)
return out
}
}

@ -0,0 +1,89 @@
import SwiftUI
struct HistoryRow: Identifiable {
let entry: Entry
let task: String
let person: String
let partner: String
var id: String { entry.id }
var date: String { entry.date }
var note: String { entry.note }
}
struct HistoryView: View {
@EnvironmentObject var store: Store
@EnvironmentObject var ui: UIState
@State private var query = ""
@State private var taskFilter = ""
@State private var selection = Set<String>()
@State private var sortOrder = [KeyPathComparator(\HistoryRow.date, order: .reverse)]
private var rows: [HistoryRow] {
let q = query.lowercased()
return store.db.entries
.filter { taskFilter.isEmpty || $0.task == taskFilter }
.map { e in
HistoryRow(entry: e, task: store.task(e.task)?.name ?? e.task, person: store.name(e.person),
partner: e.partner.isEmpty ? "" : store.name(e.partner))
}
.filter { q.isEmpty || "\($0.task) \($0.person) \($0.partner) \($0.note)".lowercased().contains(q) }
.sorted(using: sortOrder)
}
var body: some View {
let rows = rows
VStack(spacing: 0) {
HStack(spacing: 12) {
TextField("Suchen (Name, Notiz …)", text: $query)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
Picker("Aufgabe", selection: $taskFilter) {
Text("Alle Aufgaben").tag("")
ForEach(store.db.tasks.filter { !$0.partnerColumn }) { Text($0.name).tag($0.id) }
}
.frame(width: 280)
Text("\(rows.count) Einträge").foregroundStyle(Theme.muted)
Spacer()
Button("Bearbeiten") { edit(selection.first) }.disabled(selection.count != 1)
Button("Löschen", role: .destructive) { delete(selection) }.disabled(selection.isEmpty)
}
.padding(16)
Table(rows, selection: $selection, sortOrder: $sortOrder) {
TableColumn("Datum", value: \.date) { Text(Day.display($0.date)).monospacedDigit() }.width(90)
TableColumn("Aufgabe", value: \.task).width(min: 160, ideal: 220)
TableColumn("Person", value: \.person).width(min: 140, ideal: 200)
TableColumn("Partner", value: \.partner).width(min: 120, ideal: 180)
TableColumn("Thema / Notiz", value: \.note) { Text($0.note).foregroundStyle(Theme.muted) }
}
.contextMenu(forSelectionType: String.self) { ids in
if ids.count == 1 { Button("Bearbeiten") { edit(ids.first) } }
Button("Löschen", role: .destructive) { delete(ids) }
} primaryAction: { ids in edit(ids.first) }
.onDeleteCommand { delete(selection) }
.overlay {
if store.db.entries.isEmpty {
Text("Noch keine Einträge. Mit ⌘N oder einem Klick in die Übersicht geht's los.")
.foregroundStyle(Theme.muted)
}
}
}
}
private func edit(_ id: String?) {
guard let id, let e = store.db.entries.first(where: { $0.id == id }) else { return }
ui.draft = e
}
private func delete(_ ids: Set<String>) {
guard !ids.isEmpty else { return }
let alert = NSAlert()
alert.messageText = ids.count == 1 ? "Eintrag löschen?" : "\(ids.count) Einträge löschen?"
alert.informativeText = "Das lässt sich nicht rückgängig machen."
alert.addButton(withTitle: "Löschen").hasDestructiveAction = true
alert.addButton(withTitle: "Abbrechen")
guard alert.runModal() == .alertFirstButtonReturn else { return }
ids.forEach(store.delete(entry:))
selection.subtract(ids)
}
}

@ -0,0 +1,115 @@
import SwiftUI
import UniformTypeIdentifiers
enum Tab: String, CaseIterable, Identifiable {
case overview = "Übersicht", history = "Verlauf", persons = "Personen", tasks = "Aufgaben"
var id: String { rawValue }
}
/// Zustand der Oberfläche (welcher Bereich, welcher Eintrag wird gerade bearbeitet).
@MainActor
final class UIState: ObservableObject {
@Published var tab: Tab = .overview
@Published var draft: Entry?
@Published var toast: String?
func newEntry(task: String = "", person: String = "", partner: String = "") {
draft = Entry(id: "", date: Day.today, task: task, person: person, partner: partner)
}
func show(_ message: String) {
toast = message
Task { try? await Task.sleep(for: .seconds(3)); if toast == message { toast = nil } }
}
}
@main
struct MawariApp: App {
@StateObject private var store = Store()
@StateObject private var ui = UIState()
@StateObject private var updates = UpdateController()
var body: some Scene {
Window("Mawari", id: "main") {
ContentView()
.environmentObject(store)
.environmentObject(ui)
.frame(minWidth: 960, minHeight: 600)
}
.defaultSize(width: 1280, height: 820)
.commands {
CommandGroup(after: .appInfo) {
Button("Nach Updates suchen …") { updates.checkForUpdates() }
.disabled(!updates.canCheck)
}
CommandGroup(replacing: .newItem) {
Button("Neuer Eintrag") { ui.newEntry() }.keyboardShortcut("n")
}
CommandGroup(replacing: .importExport) {
Button("Export für Numbers …") { Exporter.exportXlsx(store: store, ui: ui) }.keyboardShortcut("e")
Divider()
Button("Sicherung speichern …") { Exporter.saveBackup(store: store, ui: ui) }
Button("Sicherung / HTML-Daten laden …") { Exporter.importBackup(store: store, ui: ui) }
Button("Datenordner im Finder zeigen") { NSWorkspace.shared.activateFileViewerSelecting([store.folder]) }
}
CommandMenu("Ansicht") {
ForEach(Array(Tab.allCases.enumerated()), id: \.element) { i, tab in
Button(tab.rawValue) { ui.tab = tab }.keyboardShortcut(KeyEquivalent(Character("\(i + 1)")))
}
}
}
}
}
struct ContentView: View {
@EnvironmentObject var store: Store
@EnvironmentObject var ui: UIState
var body: some View {
Group {
switch ui.tab {
case .overview: OverviewView()
case .history: HistoryView()
case .persons: PersonsView()
case .tasks: TasksView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.toolbar {
ToolbarItem(placement: .principal) {
Picker("Bereich", selection: $ui.tab) {
ForEach(Tab.allCases) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.frame(width: 380)
}
ToolbarItemGroup(placement: .primaryAction) {
Button { Exporter.exportXlsx(store: store, ui: ui) } label: {
Label("Export für Numbers", systemImage: "tablecells")
}
.help("Aktuellen Stand als Tabelle für Numbers speichern (⌘E)")
Button { ui.newEntry() } label: { Label("Neuer Eintrag", systemImage: "plus") }
.help("Neuer Eintrag (⌘N)")
}
}
.sheet(item: $ui.draft) { draft in
EntrySheet(entry: draft)
}
.overlay(alignment: .bottom) {
if let t = ui.toast ?? store.lastError {
Text(t)
.font(.callout)
.padding(.horizontal, 14).padding(.vertical, 8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))
.padding(.bottom, 18)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.2), value: ui.toast)
.tint(Theme.tokiwa)
}
}
extension Entry {
var isNew: Bool { id.isEmpty }
}

@ -0,0 +1,145 @@
import Foundation
// Datenformat ist bewusst identisch mit dem HTML-Tool (leben-und-dienst.json),
// damit dessen Dateien direkt importiert und Sicherungen dort geöffnet werden können.
enum TaskGroup: String, Codable, CaseIterable, Identifiable {
case diener, schulung
var id: String { rawValue }
var title: String { self == .diener ? "Aufgaben für Diener (Ernannte)" : "Schulungsaufgaben" }
var shortTitle: String { self == .diener ? "Diener (Ernannte)" : "Schulung" }
}
struct DutyTask: Codable, Identifiable, Hashable {
var id: String
var name: String
var group: TaskGroup
var withPartner: Bool = false
var isPartner: Bool? = nil
var partnerColumn: Bool { isPartner == true }
}
struct Person: Codable, Identifiable, Hashable {
var id: String
var name: String
var gender: String = ""
var note: String = ""
var tasks: [String] = []
func may(_ taskID: String) -> Bool { tasks.contains(taskID) }
}
struct Entry: Codable, Identifiable, Hashable {
var id: String
var date: String // YYYY-MM-DD
var task: String
var person: String
var partner: String = ""
var note: String = ""
}
struct Database: Codable {
var app = "leben-und-dienst-uebersicht"
var version = 1
var updated = ""
var tasks: [DutyTask]
var persons: [Person]
var entries: [Entry]
static let defaultTasks: [DutyTask] = ([
("vorsitz", "Vorsitz", .diener, false), ("gebet", "Gebet", .diener, false),
("schaetze", "Schätze aus Gottes Wort", .diener, false), ("graben", "Schätze graben", .diener, false),
("leben", "Leben als Christ", .diener, false), ("vbs-leitung", "VBS-Leitung", .diener, false),
("vbs-leser", "VBS-Leser", .diener, false), ("bibellesung", "Bibellesung", .schulung, false),
("gespraeche", "Gespräche beginnen", .schulung, true), ("interesse", "Interesse fördern", .schulung, true),
("juenger", "Menschen zu Jüngern machen", .schulung, true), ("glauben", "Glaubensansichten erklären", .schulung, true),
("sagen", "Was würdest du sagen?", .schulung, true), ("vortrag", "Vortrag", .schulung, false),
("partner", "Partner", .schulung, false),
] as [(String, String, TaskGroup, Bool)]).map { DutyTask(id: $0.0, name: $0.1, group: $0.2, withPartner: $0.3, isPartner: $0.0 == "partner" ? true : nil) }
static var empty: Database { Database(tasks: defaultTasks, persons: [], entries: []) }
// Tolerant gegenüber Dateien des HTML-Tools mit fehlenden Feldern.
init(tasks: [DutyTask], persons: [Person], entries: [Entry]) {
self.tasks = tasks; self.persons = persons; self.entries = entries
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
app = try c.decodeIfPresent(String.self, forKey: .app) ?? app
version = try c.decodeIfPresent(Int.self, forKey: .version) ?? 1
updated = try c.decodeIfPresent(String.self, forKey: .updated) ?? ""
let t = try c.decodeIfPresent([DutyTask].self, forKey: .tasks) ?? []
tasks = t.isEmpty ? Database.defaultTasks : t
persons = try c.decode([Person].self, forKey: .persons)
entries = try c.decode([Entry].self, forKey: .entries)
}
}
extension Person {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
gender = try c.decodeIfPresent(String.self, forKey: .gender) ?? ""
note = try c.decodeIfPresent(String.self, forKey: .note) ?? ""
tasks = try c.decodeIfPresent([String].self, forKey: .tasks) ?? []
}
}
extension Entry {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
date = try c.decode(String.self, forKey: .date)
task = try c.decode(String.self, forKey: .task)
person = try c.decode(String.self, forKey: .person)
partner = try c.decodeIfPresent(String.self, forKey: .partner) ?? ""
note = try c.decodeIfPresent(String.self, forKey: .note) ?? ""
}
}
extension DutyTask {
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
group = (try? c.decode(TaskGroup.self, forKey: .group)) ?? .schulung
withPartner = try c.decodeIfPresent(Bool.self, forKey: .withPartner) ?? false
isPartner = try c.decodeIfPresent(Bool.self, forKey: .isPartner)
}
}
// MARK: - Datum
enum Day {
nonisolated(unsafe) private static let iso: DateFormatter = {
let f = DateFormatter(); f.calendar = Calendar(identifier: .gregorian)
f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = .current; f.dateFormat = "yyyy-MM-dd"; return f
}()
nonisolated(unsafe) private static let short: DateFormatter = {
let f = DateFormatter(); f.locale = Locale(identifier: "de_DE"); f.dateFormat = "dd.MM.yy"; return f
}()
static func string(_ d: Date) -> String { iso.string(from: d) }
static func date(_ s: String) -> Date? { iso.date(from: s) }
static var today: String { string(Date()) }
static func display(_ s: String) -> String { date(s).map { short.string(from: $0) } ?? s }
static func weeksAgo(_ s: String) -> Int? {
guard let d = date(s), let t = date(today) else { return nil }
let days = Calendar.current.dateComponents([.day], from: d, to: t).day ?? 0
return days < 0 ? -1 : days / 7
}
static func agoText(_ s: String) -> String {
switch weeksAgo(s) {
case .none: return ""
case .some(let w) where w < 0: return "geplant"
case 0: return "diese Woche"
case 1: return "vor 1 Woche"
case .some(let w): return "vor \(w) Wochen"
}
}
}
func newID(_ prefix: String) -> String { prefix + UUID().uuidString.prefix(8).lowercased() }

@ -0,0 +1,242 @@
import SwiftUI
/// Sortierung einer Übersichtstabelle. `column` ist "name", "any" oder eine Aufgaben-ID.
struct OverviewSort: Equatable {
var column = "name"
var ascending = true
}
struct OverviewRow: Identifiable {
let person: Person
let any: String?
let cells: [Cell]
var id: String { person.id }
enum Cell { case notAllowed, never, date(String) }
}
/// Zeilen eines Bereichs – auch vom Export genutzt, damit er dieselbe Sortierung zeigt.
@MainActor
func overviewRows(store: Store, group: TaskGroup, sort: OverviewSort) -> (tasks: [DutyTask], rows: [OverviewRow]) {
let tasks = store.db.tasks.filter { $0.group == group }
let ids = Set(tasks.map(\.id))
let people = store.db.persons.filter { $0.tasks.contains(where: ids.contains) }
var rows = people.map { p in
OverviewRow(person: p, any: store.lastAny(person: p.id), cells: tasks.map { t in
guard p.may(t.id) else { return .notAllowed }
return store.lastDate(person: p.id, task: t).map { .date($0) } ?? .never
})
}
let byName: (OverviewRow, OverviewRow) -> Bool = { $0.person.name.localizedCompare($1.person.name) == .orderedAscending }
// "nie" zählt als am längsten her, "nicht freigegeben" steht immer am Ende.
func key(_ r: OverviewRow) -> String? {
if sort.column == "any" { return r.any ?? "0000" }
guard let i = tasks.firstIndex(where: { $0.id == sort.column }) else { return nil }
switch r.cells[i] {
case .notAllowed: return nil
case .never: return "0000"
case .date(let d): return d
}
}
rows.sort { a, b in
if sort.column == "name" { return sort.ascending ? byName(a, b) : byName(b, a) }
switch (key(a), key(b)) {
case (nil, nil): return byName(a, b)
case (nil, _): return false
case (_, nil): return true
case let (ka?, kb?):
if ka == kb { return byName(a, b) }
return sort.ascending ? ka < kb : ka > kb
}
}
return (tasks, rows)
}
struct OverviewView: View {
@EnvironmentObject var store: Store
var body: some View {
ScrollView(.vertical) {
VStack(alignment: .leading, spacing: 24) {
HStack(spacing: 16) {
legend(Text("Datum"), "letzte Einteilung")
legend(Text("nie").foregroundStyle(Theme.kohaku).bold(), "freigegeben, aber noch nie eingeteilt")
legend(Text("–").foregroundStyle(Theme.faint), "nicht freigegeben")
Spacer()
Text("Spaltenkopf klicken = sortieren · Zelle klicken = eintragen")
}
.font(.caption)
.foregroundStyle(Theme.muted)
if store.db.persons.isEmpty {
EmptyStart()
} else {
ForEach(TaskGroup.allCases) { OverviewSection(group: $0) }
}
}
.padding(24)
}
}
private func legend(_ symbol: Text, _ text: String) -> some View {
HStack(spacing: 4) { symbol; Text("= \(text)") }
}
}
struct EmptyStart: View {
@EnvironmentObject var store: Store
@EnvironmentObject var ui: UIState
var body: some View {
VStack(spacing: 14) {
Image(systemName: "person.3.sequence")
.font(.system(size: 44, weight: .light))
.foregroundStyle(Theme.tokiwa)
Text("Noch niemand eingetragen.").font(.title2.weight(.semibold))
Text("Leg unter „Personen“ alle an, die mitmachen, und hak an, wer was übernehmen darf.")
.foregroundStyle(Theme.muted)
HStack {
Button("Personen anlegen") { ui.tab = .persons }.buttonStyle(.borderedProminent)
Button("Daten aus dem HTML-Tool laden …") { Exporter.importBackup(store: store, ui: ui) }
}
.padding(.top, 6)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 80)
}
}
struct OverviewSection: View {
let group: TaskGroup
@EnvironmentObject var store: Store
@EnvironmentObject var ui: UIState
@AppStorage private var sortColumn: String
@AppStorage private var sortAscending: Bool
init(group: TaskGroup) {
self.group = group
_sortColumn = AppStorage(wrappedValue: "name", "sort.\(group.rawValue).column")
_sortAscending = AppStorage(wrappedValue: true, "sort.\(group.rawValue).ascending")
}
private let nameWidth: CGFloat = 210
private let anyWidth: CGFloat = 100
private let cellWidth: CGFloat = 108
var body: some View {
let sort = OverviewSort(column: sortColumn, ascending: sortAscending)
let (tasks, rows) = overviewRows(store: store, group: group, sort: sort)
let color = Theme.color(for: group)
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
Text(group.title).font(.headline)
Text("\(rows.count) Personen").font(.subheadline).opacity(0.8)
}
.foregroundStyle(.white)
.padding(.horizontal, 14).padding(.vertical, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(color)
ScrollView(.horizontal) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 0) {
header("Name", column: "name", width: nameWidth, alignment: .leading)
header("Zuletzt (irgendeine)", column: "any", width: anyWidth)
ForEach(tasks) { header($0.name, column: $0.id, width: cellWidth) }
}
.background(Theme.soft(for: group))
if rows.isEmpty {
Text("Noch niemand freigegeben – unter „Personen“ Häkchen setzen.")
.foregroundStyle(Theme.muted)
.padding(14)
}
ForEach(rows) { row in
HStack(spacing: 0) {
Text(row.person.name)
.fontWeight(.medium)
.lineLimit(1)
.padding(.horizontal, 12)
.frame(width: nameWidth, alignment: .leading)
Text(row.any.map(Day.display) ?? "nie")
.monospacedDigit()
.foregroundStyle(Theme.muted)
.frame(width: anyWidth)
ForEach(Array(zip(tasks, row.cells)), id: \.0.id) { task, cell in
OverviewCell(cell: cell, width: cellWidth) {
if task.partnerColumn { ui.newEntry(partner: row.person.id) }
else { ui.newEntry(task: task.id, person: row.person.id) }
}
.help("\(task.name) · \(row.person.name)")
}
}
.frame(height: 44)
.overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) }
}
}
}
}
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Theme.line))
}
private func header(_ title: String, column: String, width: CGFloat, alignment: Alignment = .center) -> some View {
Button {
if sortColumn == column { sortAscending.toggle() } else { sortColumn = column; sortAscending = true }
} label: {
HStack(spacing: 3) {
if sortColumn == column {
Image(systemName: sortAscending ? "chevron.up" : "chevron.down")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(Theme.color(for: group))
}
Text(title)
.font(.caption.weight(.semibold))
.multilineTextAlignment(alignment == .leading ? .leading : .center)
.lineLimit(2)
}
.foregroundStyle(sortColumn == column ? Color.primary : Theme.muted)
.padding(.horizontal, alignment == .leading ? 12 : 6)
.frame(width: width, height: 46, alignment: alignment == .leading ? .bottomLeading : .bottom)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(column == "name" ? "Nach Name sortieren" : "Sortieren: wer am längsten nicht dran war, steht oben")
}
}
struct OverviewCell: View {
let cell: OverviewRow.Cell
let width: CGFloat
let action: () -> Void
@State private var hover = false
var body: some View {
switch cell {
case .notAllowed:
Text("–").foregroundStyle(Theme.faint).frame(width: width, height: 44)
case .never:
Button(action: action) {
Text("nie").fontWeight(.medium).foregroundStyle(Theme.kohaku)
.frame(width: width, height: 44)
.background(hover ? Theme.kohaku.opacity(0.22) : Theme.kohakuSoft)
.contentShape(Rectangle())
}
.buttonStyle(.plain).onHover { hover = $0 }
case .date(let d):
Button(action: action) {
VStack(spacing: 1) {
Text(Day.display(d)).monospacedDigit()
Text(Day.agoText(d)).font(.caption2).foregroundStyle(Theme.muted)
}
.frame(width: width, height: 44)
.background(hover ? Color.primary.opacity(0.06) : .clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain).onHover { hover = $0 }
}
}
}

@ -0,0 +1,143 @@
import SwiftUI
struct PersonsView: View {
@EnvironmentObject var store: Store
@State private var newName = ""
@FocusState private var newFocused: Bool
private let nameWidth: CGFloat = 220
private let genderWidth: CGFloat = 120
private let noteWidth: CGFloat = 180
private let checkWidth: CGFloat = 32
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
TextField("Name der neuen Person", text: $newName)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
.focused($newFocused)
.onSubmit(add)
Button("Hinzufügen", action: add).buttonStyle(.borderedProminent)
.disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
Text("Häkchen = darf diese Aufgabe übernehmen. Wer mindestens eine Diener-Aufgabe hat, erscheint oben in der Übersicht, wer eine Schulungsaufgabe hat, unten.")
.font(.caption).foregroundStyle(Theme.muted)
.fixedSize(horizontal: false, vertical: true)
}
.padding(16)
ScrollView([.horizontal, .vertical]) {
VStack(alignment: .leading, spacing: 0) {
headerRow
ForEach(store.personsSorted) { p in
if let binding = store.binding(person: p.id) {
PersonRow(person: binding, widths: (nameWidth, genderWidth, noteWidth, checkWidth))
}
}
}
.padding([.horizontal, .bottom], 16)
}
}
}
private var headerRow: some View {
HStack(alignment: .bottom, spacing: 0) {
Text("Name").frame(width: nameWidth, alignment: .leading).padding(.leading, 4)
Text("Bruder/Schwester").frame(width: genderWidth, alignment: .leading)
Text("Notiz").frame(width: noteWidth, alignment: .leading)
ForEach(store.db.tasks) { t in
Text(t.name)
.lineLimit(1)
.fixedSize()
.rotationEffect(.degrees(-90))
.frame(width: checkWidth, height: 170, alignment: .center)
.background(Theme.soft(for: t.group))
.help(t.name)
}
Spacer().frame(width: 80)
}
.font(.caption.weight(.semibold))
.foregroundStyle(Theme.muted)
.padding(.bottom, 6)
.overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) }
}
private func add() {
if store.addPerson(named: newName) { newName = "" } else { NSSound.beep() }
newFocused = true
}
}
struct PersonRow: View {
@Binding var person: Person
let widths: (name: CGFloat, gender: CGFloat, note: CGFloat, check: CGFloat)
@EnvironmentObject var store: Store
@State private var name = ""
@FocusState private var focused: Bool
var body: some View {
HStack(spacing: 0) {
TextField("Name", text: $name)
.textFieldStyle(.roundedBorder)
.frame(width: widths.name - 12)
.padding(.trailing, 12)
.onAppear { name = person.name }
.focused($focused)
.onSubmit(commitName)
.onChange(of: focused) { _, now in if !now { commitName() } }
.onDisappear(perform: commitName)
Picker("", selection: $person.gender) {
Text("–").tag("")
Text("Bruder").tag("Bruder")
Text("Schwester").tag("Schwester")
}
.labelsHidden()
.frame(width: widths.gender - 12)
.padding(.trailing, 12)
TextField("", text: $person.note)
.textFieldStyle(.roundedBorder)
.frame(width: widths.note - 12)
.padding(.trailing, 12)
ForEach(store.db.tasks) { t in
Toggle("", isOn: Binding(
get: { person.tasks.contains(t.id) },
set: { on in
if on { if !person.tasks.contains(t.id) { person.tasks.append(t.id) } }
else { person.tasks.removeAll { $0 == t.id } }
}))
.toggleStyle(.checkbox)
.labelsHidden()
.frame(width: widths.check)
.help(t.name)
}
Button("Löschen", role: .destructive, action: delete)
.buttonStyle(.link)
.foregroundStyle(Theme.bengara)
.frame(width: 80)
}
.padding(.vertical, 5)
.overlay(alignment: .bottom) { Rectangle().fill(Theme.line).frame(height: 1) }
}
private func commitName() {
let n = name.trimmingCharacters(in: .whitespaces)
guard n != person.name else { return }
if n.isEmpty || store.db.persons.contains(where: { $0.id != person.id && $0.name.lowercased() == n.lowercased() }) {
NSSound.beep(); name = person.name; return
}
person.name = n
}
private func delete() {
let count = store.entryCount(person: person.id)
let alert = NSAlert()
alert.messageText = "\(person.name) löschen?"
alert.informativeText = count > 0
? "\(count) Einträge im Verlauf gehören zu dieser Person und werden ebenfalls gelöscht."
: "Die Person hat noch keine Einträge im Verlauf."
alert.addButton(withTitle: "Löschen").hasDestructiveAction = true
alert.addButton(withTitle: "Abbrechen")
guard alert.runModal() == .alertFirstButtonReturn else { return }
store.deletePerson(person.id)
}
}

@ -0,0 +1,139 @@
import Foundation
import SwiftUI
/// Hält alle Daten und speichert jede Änderung sofort in
/// ~/Library/Application Support/Mawari/daten.json. Einmal am Tag wird eine
/// Sicherungskopie in "Sicherungen" abgelegt (die letzten 60 bleiben).
@MainActor
final class Store: ObservableObject {
@Published var db: Database { didSet { scheduleSave() } }
@Published var lastError: String?
let folder: URL
private var file: URL { folder.appendingPathComponent("daten.json") }
private var backups: URL { folder.appendingPathComponent("Sicherungen") }
private var saveTask: Task<Void, Never>?
init() {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
// MAWARI_DATA_DIR: nur zum Testen, damit echte Daten unberührt bleiben.
if let dir = ProcessInfo.processInfo.environment["MAWARI_DATA_DIR"] {
folder = URL(fileURLWithPath: dir, isDirectory: true)
} else {
folder = base.appendingPathComponent("Mawari", isDirectory: true)
}
try? FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
if let data = try? Data(contentsOf: folder.appendingPathComponent("daten.json")),
let loaded = try? JSONDecoder().decode(Database.self, from: data) {
db = loaded
} else {
db = .empty
}
dailyBackup()
}
// MARK: Speichern
private func scheduleSave() {
saveTask?.cancel()
saveTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
self?.saveNow()
}
}
func saveNow() {
do {
try encoded().write(to: file, options: .atomic)
lastError = nil
} catch {
lastError = "Speichern fehlgeschlagen: \(error.localizedDescription)"
}
}
func encoded() throws -> Data {
var copy = db
copy.updated = ISO8601DateFormatter().string(from: Date())
let enc = JSONEncoder()
enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try enc.encode(copy)
}
private func dailyBackup() {
guard FileManager.default.fileExists(atPath: file.path) else { return }
try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
let target = backups.appendingPathComponent("daten-\(Day.today).json")
if !FileManager.default.fileExists(atPath: target.path) {
try? FileManager.default.copyItem(at: file, to: target)
}
let all = (try? FileManager.default.contentsOfDirectory(at: backups, includingPropertiesForKeys: nil)) ?? []
for old in all.filter({ $0.pathExtension == "json" }).sorted(by: { $0.lastPathComponent > $1.lastPathComponent }).dropFirst(60) {
try? FileManager.default.removeItem(at: old)
}
}
// MARK: Import / Sicherung
func importFile(_ url: URL) throws {
let access = url.startAccessingSecurityScopedResource()
defer { if access { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let loaded = try JSONDecoder().decode(Database.self, from: data)
saveNow() // aktuellen Stand vorher sichern
let safety = backups.appendingPathComponent("vor-import-\(Int(Date().timeIntervalSince1970)).json")
try? FileManager.default.createDirectory(at: backups, withIntermediateDirectories: true)
try? FileManager.default.copyItem(at: file, to: safety)
db = loaded
}
// MARK: Abfragen
func task(_ id: String) -> DutyTask? { db.tasks.first { $0.id == id } }
func person(_ id: String) -> Person? { db.persons.first { $0.id == id } }
func name(_ id: String) -> String { person(id)?.name ?? "(gelöscht)" }
var personsSorted: [Person] { db.persons.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } }
func lastDate(person: String, task: DutyTask) -> String? {
var best: String?
for e in db.entries {
let hit = task.partnerColumn ? e.partner == person : (e.task == task.id && e.person == person)
if hit, e.date > (best ?? "") { best = e.date }
}
return best
}
func lastAny(person: String) -> String? {
var best: String?
for e in db.entries where (e.person == person || e.partner == person) && e.date > (best ?? "") { best = e.date }
return best
}
// MARK: Ändern
func save(entry: Entry) {
if let i = db.entries.firstIndex(where: { $0.id == entry.id }) { db.entries[i] = entry } else { db.entries.append(entry) }
}
func delete(entry id: String) { db.entries.removeAll { $0.id == id } }
func addPerson(named name: String) -> Bool {
let n = name.trimmingCharacters(in: .whitespaces)
guard !n.isEmpty, !db.persons.contains(where: { $0.name.lowercased() == n.lowercased() }) else { return false }
db.persons.append(Person(id: newID("p"), name: n))
return true
}
func deletePerson(_ id: String) {
db.entries.removeAll { $0.person == id }
for i in db.entries.indices where db.entries[i].partner == id { db.entries[i].partner = "" }
db.persons.removeAll { $0.id == id }
}
func entryCount(person id: String) -> Int { db.entries.filter { $0.person == id || $0.partner == id }.count }
func entryCount(task: DutyTask) -> Int { task.partnerColumn ? db.entries.filter { !$0.partner.isEmpty }.count : db.entries.filter { $0.task == task.id }.count }
func binding(person id: String) -> Binding<Person>? {
guard db.persons.contains(where: { $0.id == id }) else { return nil }
return Binding(
get: { self.db.persons.first { $0.id == id } ?? Person(id: id, name: "") },
set: { new in if let i = self.db.persons.firstIndex(where: { $0.id == id }) { self.db.persons[i] = new } })
}
}

@ -0,0 +1,76 @@
import SwiftUI
struct TasksView: View {
@EnvironmentObject var store: Store
@State private var newName = ""
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 10) {
TextField("Neue Aufgabe", text: $newName)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
.onSubmit(add)
Button("Hinzufügen", action: add).buttonStyle(.borderedProminent)
.disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty)
Text("Reihenfolge = Spalten in der Übersicht (ziehen zum Umsortieren). „Mit Partner“ blendet beim Eintragen das Partner-Feld ein.")
.font(.caption).foregroundStyle(Theme.muted)
}
.padding(16)
List {
ForEach($store.db.tasks) { $task in
HStack(spacing: 16) {
TextField("Name", text: $task.name)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
Picker("", selection: $task.group) {
ForEach(TaskGroup.allCases) { Text($0.shortTitle).tag($0) }
}
.labelsHidden()
.frame(width: 170)
if task.partnerColumn {
Text("zählt die Partner").foregroundStyle(Theme.muted).frame(width: 120, alignment: .leading)
} else {
Toggle("Mit Partner", isOn: $task.withPartner).frame(width: 120, alignment: .leading)
}
Text(count(store.entryCount(task: task))).foregroundStyle(Theme.muted).frame(width: 90, alignment: .leading)
Spacer()
if !task.partnerColumn {
Button("Löschen", role: .destructive) { delete(task) }
.buttonStyle(.link).foregroundStyle(Theme.bengara)
}
}
.padding(.vertical, 3)
}
.onMove { store.db.tasks.move(fromOffsets: $0, toOffset: $1) }
}
}
}
private func count(_ n: Int) -> String { n == 1 ? "1 Eintrag" : "\(n) Einträge" }
private func add() {
let n = newName.trimmingCharacters(in: .whitespaces)
guard !n.isEmpty else { return }
let at = store.db.tasks.firstIndex(where: \.partnerColumn) ?? store.db.tasks.endIndex
store.db.tasks.insert(DutyTask(id: newID("t"), name: n, group: .schulung), at: at)
newName = ""
}
private func delete(_ task: DutyTask) {
let n = store.entryCount(task: task)
let alert = NSAlert()
if n > 0 {
alert.messageText = "„\(task.name)“ hat \(n) Einträge im Verlauf."
alert.informativeText = "Aufgaben mit Einträgen lassen sich nicht löschen. Du kannst sie umbenennen."
alert.runModal(); return
}
alert.messageText = "Aufgabe „\(task.name)“ löschen?"
alert.addButton(withTitle: "Löschen").hasDestructiveAction = true
alert.addButton(withTitle: "Abbrechen")
guard alert.runModal() == .alertFirstButtonReturn else { return }
store.db.tasks.removeAll { $0.id == task.id }
for i in store.db.persons.indices { store.db.persons[i].tasks.removeAll { $0 == task.id } }
}
}

@ -0,0 +1,35 @@
import SwiftUI
import AppKit
/// Farbwelt von Mawari: Diensttafeln aus Holz (Hinoki), immergrünes Tokiwa als
/// Markenfarbe, Kastanie (Kuri) für die Schulungsaufgaben. Hell ist Standard,
/// dunkel wird unterstützt. Siehe design/README.md.
enum Theme {
static let tokiwa = adaptive(light: 0x2F6B4F, dark: 0x6DBB93) // Marke, Diener-Bereich
static let kuri = adaptive(light: 0x7A5230, dark: 0xD0A07A) // Schulungsbereich
static let tokiwaSoft = adaptive(light: 0xE4EFE8, dark: 0x1F2D26)
static let kuriSoft = adaptive(light: 0xF2E9DF, dark: 0x2E2620)
static let kohaku = adaptive(light: 0xB45309, dark: 0xF0A35A) // "nie"
static let kohakuSoft = adaptive(light: 0xFDF1E3, dark: 0x3A2A18)
static let line = adaptive(light: 0xE4E0D8, dark: 0x30332F)
static let muted = adaptive(light: 0x7A746B, dark: 0x9A958C)
static let faint = adaptive(light: 0xC6C0B6, dark: 0x4C4F4A)
static let bengara = adaptive(light: 0xB5452B, dark: 0xE8775D) // Warnungen
static func color(for group: TaskGroup) -> Color { group == .diener ? tokiwa : kuri }
static func soft(for group: TaskGroup) -> Color { group == .diener ? tokiwaSoft : kuriSoft }
static func adaptive(light: UInt32, dark: UInt32) -> Color {
Color(nsColor: NSColor(name: nil) { appearance in
let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
return NSColor(hex: isDark ? dark : light)
})
}
}
extension NSColor {
convenience init(hex: UInt32) {
self.init(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255,
blue: CGFloat(hex & 0xFF) / 255, alpha: 1)
}
}

@ -0,0 +1,22 @@
import Foundation
import Sparkle
/// Selbstaktualisierung über Sparkle. Mawari sucht einmal am Tag von selbst
/// (SUEnableAutomaticChecks in project.yml) und bietet gefundene Updates an;
/// installiert wird erst nach Zustimmung. Appcast: update.zielke.studio/mawari.
@MainActor
final class UpdateController: ObservableObject {
private let controller: SPUStandardUpdaterController
@Published private(set) var canCheck = true
private var observation: NSKeyValueObservation?
init() {
controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
observation = controller.updater.observe(\.canCheckForUpdates, options: [.initial, .new]) { [weak self] updater, _ in
let can = updater.canCheckForUpdates
Task { @MainActor in self?.canCheck = can }
}
}
func checkForUpdates() { controller.updater.checkForUpdates() }
}

@ -0,0 +1,14 @@
# Changelog
## 1.0.0 (Build 1) – 2026-10-22
Erste Version.
- Übersicht in zwei Bereichen (Diener/Ernannte, Schulung): je Person und Aufgabe das letzte Datum, „nie“ oder „–“
- Sortieren per Spaltenkopf, Eintrag per Klick auf eine Zelle mit Person und Aufgabe vorbelegt
- Verlauf, Personen mit Freigaben, Aufgaben pflegen
- Export für Numbers (.xlsx)
- Sicherung speichern/laden, Übernahme der Daten aus dem früheren HTML-Tool
- Automatisches Speichern, tägliche Sicherungskopie (60 Tage)
- Selbstaktualisierung über Sparkle (update.zielke.studio/mawari)
- Signiert und notarisiert, Universal (Apple Silicon + Intel), macOS 14+

@ -0,0 +1,23 @@
# CLAUDE.md – Mawari
Du arbeitest an **Mawari**, einer kleinen nativen macOS-App von Zielke Studio. Sie zeigt für die Leben-und-Dienst-Zusammenkunft, wer wann zuletzt welche Aufgabe hatte. Die offizielle Einteilung bleibt in NW Scheduler; Mawari ist nur das Nachschlagewerk daneben. Nutzer ist eine einzelne Person ohne Technikinteresse – alles muss ohne Erklärung funktionieren.
## Technische Leitplanken
- **Plattform:** macOS 14+, Universal Build (arm64 + x86_64), Swift 6, SwiftUI (AppKit nur für Panels und Alerts).
- **Projekt:** `Mawari.xcodeproj` wird aus `project.yml` erzeugt (`xcodegen`) und ist nicht im Repo. `App/Info.plist` ebenso.
- **Abhängigkeiten:** nur Sparkle 2.10.0 (gleiche Fassung wie drop.look/drop.two). Keine weiteren Pakete ohne Rückfrage.
- **Datenformat:** muss kompatibel zum früheren HTML-Tool bleiben (`docs/datenformat.md`). Neue Felder nur optional und mit Standardwert dekodieren.
- **Echte Daten nie anfassen:** Zum Testen `MAWARI_DATA_DIR=/tmp/irgendwas` setzen.
- **Bewusst nicht im Umfang:** Vorschläge, wer als Nächstes dran ist; Abwesenheiten; Live-Verknüpfung mit NW Scheduler.
## Design
Farben in `App/Sources/Theme.swift`, Begründung in `design/README.md`. Hell ist Standard, dunkel wird unterstützt. Texte auf Deutsch, kurz, in Du-Form.
## Arbeitsweise
1. Vor Code: kurzer Plan in wenigen Punkten.
2. Nach jedem lauffähigen Stand committen (Conventional Commits auf Englisch).
3. Kommentare im Code auf Deutsch (wie bisher), UI-Texte auf Deutsch.
4. Neue Version: Ablauf in `README.md` → „Neue Version veröffentlichen“, Eintrag in `CHANGELOG.md`.

@ -0,0 +1,89 @@
# Mawari
**Wer war wann dran? Auf einen Blick.**
Mawari (回り, japanisch: „reihum, die Runde“) ist eine macOS-App von Zielke Studio für die Planung der Leben-und-Dienst-Zusammenkunft. Die offizielle Einteilung läuft weiter über die bestehende App (NW Scheduler). Mawari ergänzt sie um die eine Frage, die dort untergeht: **Wann hatte wer zuletzt welche Aufgabe?**
- Übersicht in zwei Bereichen: Aufgaben für Diener (Ernannte) und Schulungsaufgaben
- je Person und Aufgabe das letzte Datum, „nie“ oder „–“ (nicht freigegeben)
- Spaltenkopf klicken = sortieren (wer am längsten nicht dran war, steht oben)
- Zelle klicken = Eintrag mit Person und Aufgabe vorbelegt
- Export für Numbers (.xlsx, öffnet sich direkt in Numbers)
- bewusst **keine** Vorschläge, keine Abwesenheiten, keine Verknüpfung mit der App
## Dokumente
| Datei | Inhalt |
|---|---|
| [CLAUDE.md](CLAUDE.md) | Arbeitsanweisungen für Claude Code |
| [CHANGELOG.md](CHANGELOG.md) | Versionen |
| [docs/datenformat.md](docs/datenformat.md) | Aufbau von `daten.json`, Sicherungen, Kompatibilität zum HTML-Tool |
| [design/README.md](design/README.md) | Farben, Symbol, Texte |
## Daten
`~/Library/Application Support/Mawari/daten.json`, jede Änderung wird sofort gespeichert. Einmal täglich landet eine Kopie in `Sicherungen/` (die letzten 60 bleiben). Das Format ist identisch mit dem früheren HTML-Tool (`leben-und-dienst.json`), dessen Dateien lassen sich über *Ablage › Sicherung / HTML-Daten laden …* übernehmen.
Zum Testen ohne echte Daten: `MAWARI_DATA_DIR=/irgendein/ordner …/Mawari.app/Contents/MacOS/Mawari`.
## Bauen
```bash
brew install xcodegen # einmalig
xcodegen # erzeugt Mawari.xcodeproj aus project.yml
open Mawari.xcodeproj
```
macOS 14+, Universal (arm64 + x86_64), Swift 6, SwiftUI. Einzige Abhängigkeit: Sparkle 2.10.0.
Symbol neu zeichnen: `swift Tools/make-icon.swift`.
Das `.xcodeproj` und `App/Info.plist` sind generiert und nicht im Repo. Nach Änderungen an `project.yml` erneut `xcodegen` ausführen.
Signiert wird mit **Developer ID Application** (Team `XY4C2RG3L5`), Hardened Runtime an. Ohne dieses Zertifikat `CODE_SIGN_IDENTITY`/`DEVELOPMENT_TEAM` in `project.yml` anpassen.
## Struktur
| Pfad | Inhalt |
|---|---|
| `App/Sources/MawariApp.swift` | Einstieg, Fenster, Menüs, Oberflächenzustand |
| `App/Sources/Model.swift` | Datenmodell (Aufgaben, Personen, Einträge), Datumshilfen |
| `App/Sources/Store.swift` | Laden, Speichern, tägliche Sicherung, Abfragen |
| `App/Sources/OverviewView.swift` | Übersicht „wer war wann dran“ mit Sortierung |
| `App/Sources/HistoryView.swift`, `EntrySheet.swift` | Verlauf und Eintrag bearbeiten |
| `App/Sources/PersonsView.swift`, `TasksView.swift` | Personen (mit Freigaben) und Aufgaben pflegen |
| `App/Sources/Exporter.swift` | .xlsx-Export (eigener Writer, ohne Abhängigkeit), Sicherung speichern/laden |
| `App/Sources/Theme.swift` | Farben |
| `App/Sources/UpdateController.swift` | Sparkle |
| `Tools/` | Symbol erzeugen, DMG bauen, Appcast, Veröffentlichen |
| `design/` | Symbolvorlage und Designnotizen |
| `project.yml` | XcodeGen-Projektdefinition |
## Neue Version veröffentlichen
1. In `project.yml` `MARKETING_VERSION` erhöhen und `CURRENT_PROJECT_VERSION` um 1 hochzählen.
2. `bash Tools/build-dmg.sh` – Release-Build, Signatur (Developer ID), Notarisierung von App und DMG.
3. `bash Tools/make-appcast.sh "Was ist neu – ein Satz"` – signiert das DMG mit dem Sparkle-Schlüssel, schreibt `build/appcast.xml`.
4. `bash Tools/publish.sh` – lädt DMG und Appcast nach `update.zielke.studio/mawari/`.
Installierte Kopien suchen einmal am Tag nach Updates und bieten sie an. Von Hand: *Mawari › Nach Updates suchen …*
**Einmalig vor dem ersten Veröffentlichen:** Ordner auf dem Server anlegen (siehe `zielke-apps/README.md`):
```bash
ssh -t samuelzielke@10.0.5.223 'sudo install -d -o samuelzielke -g www-data -m 2775 /var/www/updates/mawari'
```
**Schlüssel sichern:** Der private Sparkle-Schlüssel liegt im Schlüsselbund dieses Macs (Dienst `https://sparkle-project.org`, Konto `mawari`). Geht er verloren, kann keine installierte Kopie mehr aktualisiert werden. Export: `generate_keys --account mawari -x mawari-sparkle.key` (liegt nach dem ersten Build unter `build/dd/SourcePackages/artifacts/sparkle/Sparkle/bin/`), Datei in die Passwörter-App, dann löschen.
Notarisierung nutzt das Profil `patchcord-notary` (gleiches Team). Anderes Profil: `NOTARY_PROFILE=… bash Tools/build-dmg.sh`.
## Ausblick: Anbindung an NW Scheduler
Geprüft, noch nicht umgesetzt. Eine Live-Schnittstelle (API) bietet NW Scheduler nicht. Möglich ist ein **Import aus Dateien**:
- NW Scheduler kann die Einteilungen der Leben-und-Dienst-Zusammenkunft als CSV ausgeben (Spalten `Date, Person, PartType, Assignment, School`, PartType z. B. `Chairman`, `OpeningPrayer`, `TreasuresTalk`, `Gems`, `BibleReading`, `Apply1–4`, `Living1–3`, `CBS`, `CBSReader`). Andere Tools lesen genau dieses Format ein.
- Außerdem gibt es Sicherungspakete, die z. B. CongPlan inklusive Zuteilungsverlauf übernimmt.
Ein Upgrade wäre also: *Ablage › Aus NW Scheduler importieren …* liest die CSV, ordnet `PartType` den Mawari-Aufgaben zu, gleicht Namen mit den Personen ab und ergänzt nur neue Einträge. Vorher an einer echten Exportdatei prüfen, wie Namen und Partner darin stehen.
© Zielke Studio

@ -0,0 +1,79 @@
#!/bin/bash
# Baut Mawari signiert und notarisiert als DMG (zum Verschicken und für Updates).
#
# Voraussetzungen (einmalig):
# - Developer-ID-Application-Zertifikat im Login-Schlüsselbund (Team XY4C2RG3L5)
# - notarytool-Profil. Gleiches Team wie Patchcord, daher dessen Profil als Standard:
# NOTARY_PROFILE=patchcord-notary
#
# Aufruf: bash Tools/build-dmg.sh
set -euo pipefail
cd "$(dirname "$0")/.."
TEAM_ID="XY4C2RG3L5"
NOTARY_PROFILE="${NOTARY_PROFILE:-patchcord-notary}"
VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/')
APP_NAME="Mawari"
BUILD_DIR="build"
ARCHIVE_PATH="$BUILD_DIR/Mawari.xcarchive"
DMG_STAGING="$BUILD_DIR/dmg-staging"
DMG_PATH="$BUILD_DIR/${APP_NAME}-${VERSION}.dmg"
SIGN_ID="Developer ID Application: Samuel Zielke ($TEAM_ID)"
echo "==> Version $VERSION, Team $TEAM_ID, Notarisierung über Profil $NOTARY_PROFILE"
xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" >/dev/null 2>&1 \
|| { echo "Fehler: notarytool-Profil $NOTARY_PROFILE fehlt (xcrun notarytool store-credentials …)." >&2; exit 1; }
rm -rf "$ARCHIVE_PATH" "$DMG_STAGING" "$DMG_PATH"
mkdir -p "$DMG_STAGING"
command -v xcodegen >/dev/null && xcodegen >/dev/null
echo "==> Archiviere (Release, Universal)"
xcodebuild -project Mawari.xcodeproj -scheme Mawari -configuration Release \
-archivePath "$ARCHIVE_PATH" -derivedDataPath "$BUILD_DIR/dd-release" \
CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY="Developer ID Application" DEVELOPMENT_TEAM="$TEAM_ID" \
archive -quiet
APP_PATH="$ARCHIVE_PATH/Products/Applications/${APP_NAME}.app"
[ -d "$APP_PATH" ] || { echo "Fehler: $APP_PATH nicht gefunden." >&2; exit 1; }
# Sparkles Hilfsprogramme signiert Xcode nur ad-hoc – die Notarisierung lehnt das ab.
# Von innen nach außen nachsignieren (siehe drop.look/Tools/build-beta-dmg.sh).
echo "==> Sparkle nachsignieren"
resign() { [ -e "$1" ] || return 0; codesign --force --timestamp --options runtime --sign "$SIGN_ID" "$1"; }
SPARKLE="$APP_PATH/Contents/Frameworks/Sparkle.framework"
if [ -d "$SPARKLE" ]; then
V="$SPARKLE/Versions/Current"
resign "$V/XPCServices/Downloader.xpc"
resign "$V/XPCServices/Installer.xpc"
resign "$V/Autoupdate"
resign "$V/Updater.app"
resign "$SPARKLE"
fi
echo "==> App signieren"
codesign --force --timestamp --options runtime --entitlements App/Mawari.entitlements --sign "$SIGN_ID" "$APP_PATH"
codesign --verify --deep --strict --verbose=2 "$APP_PATH"
lipo -archs "$APP_PATH/Contents/MacOS/$APP_NAME"
echo "==> App notarisieren (dauert ein paar Minuten)"
ZIP_PATH="$BUILD_DIR/${APP_NAME}-notarize.zip"
rm -f "$ZIP_PATH"
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
xcrun notarytool submit "$ZIP_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$APP_PATH"
spctl --assess --type execute --verbose "$APP_PATH"
echo "==> DMG bauen"
cp -R "$APP_PATH" "$DMG_STAGING/"
ln -s /Applications "$DMG_STAGING/Programme"
hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGING" -ov -format UDZO "$DMG_PATH" -quiet
codesign --force --timestamp --sign "$SIGN_ID" "$DMG_PATH"
echo "==> DMG notarisieren (damit sie auch per Mail verschickt ohne Warnung aufgeht)"
xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple "$DMG_PATH"
spctl --assess --type open --context context:primary-signature --verbose "$DMG_PATH"
echo "==> Fertig: $DMG_PATH"
echo "Für ein Online-Update danach: bash Tools/make-appcast.sh && bash Tools/publish.sh"

@ -0,0 +1,48 @@
#!/bin/bash
# Signiert das fertige DMG mit Mawaris Sparkle-Schlüssel (Schlüsselbund, Konto "mawari")
# und schreibt build/appcast.xml. Danach: bash Tools/publish.sh
#
# Aufruf: bash Tools/make-appcast.sh ["Was ist neu – ein Satz"]
set -euo pipefail
cd "$(dirname "$0")/.."
BASE_URL="https://update.zielke.studio/mawari"
VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/')
BUILD=$(grep -m1 'CURRENT_PROJECT_VERSION:' project.yml | sed -E 's/.*CURRENT_PROJECT_VERSION: *"?([^"]+)"?/\1/')
MIN_OS=$(grep -m1 'macOS:' project.yml | sed -E 's/.*macOS: *"?([^"]+)"?/\1/')
DMG="build/Mawari-${VERSION}.dmg"
NOTES="${1:-}"
SIGN_UPDATE=$(find build/dd build/dd-release -path "*artifacts/sparkle/Sparkle/bin/sign_update" 2>/dev/null | head -1)
[ -x "$SIGN_UPDATE" ] || { echo "Fehler: sign_update fehlt – erst einmal bauen." >&2; exit 1; }
[ -f "$DMG" ] || { echo "Fehler: $DMG fehlt – erst Tools/build-dmg.sh." >&2; exit 1; }
SIG_LINE=$("$SIGN_UPDATE" --account mawari "$DMG")
ED_SIG=$(echo "$SIG_LINE" | sed -E 's/.*sparkle:edSignature="([^"]+)".*/\1/')
LENGTH=$(echo "$SIG_LINE" | sed -E 's/.*length="([^"]+)".*/\1/')
[ -n "$ED_SIG" ] && [ "$ED_SIG" != "$SIG_LINE" ] || { echo "Fehler beim Signieren: $SIG_LINE" >&2; exit 1; }
DESC=""
[ -n "$NOTES" ] && DESC="<description><![CDATA[<p>${NOTES}</p>]]></description>"
cat > build/appcast.xml <<XML
<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Mawari</title>
<link>${BASE_URL}/appcast.xml</link>
<description>Updates für Mawari</description>
<language>de</language>
<item>
<title>${VERSION}</title>
${DESC}
<pubDate>$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')</pubDate>
<sparkle:version>${BUILD}</sparkle:version>
<sparkle:shortVersionString>${VERSION}</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>${MIN_OS}</sparkle:minimumSystemVersion>
<enclosure url="${BASE_URL}/Mawari-${VERSION}.dmg" sparkle:edSignature="${ED_SIG}" length="${LENGTH}" type="application/octet-stream"/>
</item>
</channel>
</rss>
XML
echo "==> build/appcast.xml für Version $VERSION (Build $BUILD) geschrieben"

@ -0,0 +1,117 @@
import Foundation
import AppKit
// Zeichnet das App-Symbol von Mawari (回り, „reihum“): eine Diensttafel aus Hinoki-Holz
// mit drei hängenden Namensschildern. Das mittlere ist auf die rote Seite
// gedreht – „ist dran“. Grund in Tokiwa-Grün.
//
// Aufruf: swift Tools/make-icon.swift (schreibt App/Resources/AppIcon.icns
// und design/icon-1024.png)
func rgb(_ hex: UInt32, _ a: CGFloat = 1) -> CGColor {
CGColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: a)
}
func drawIcon(size: CGFloat) -> CGImage {
let cs = CGColorSpaceCreateDeviceRGB()
let ctx = CGContext(data: nil, width: Int(size), height: Int(size), bitsPerComponent: 8, bytesPerRow: 0,
space: cs, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
let s = size / 1024
let inset = 100 * s
let rect = CGRect(x: inset, y: inset, width: size - inset * 2, height: size - inset * 2)
let radius = rect.width * 0.2237
// Schatten unter der Kachel
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -10 * s), blur: 24 * s, color: rgb(0x000000, 0.28))
ctx.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil))
ctx.setFillColor(rgb(0x2F6B4F)); ctx.fillPath()
ctx.restoreGState()
ctx.saveGState()
ctx.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil))
ctx.clip()
let bg = CGGradient(colorsSpace: cs, colors: [rgb(0x1E4A36), rgb(0x2F6B4F), rgb(0x3E8462)] as CFArray, locations: [0, 0.55, 1])!
ctx.drawLinearGradient(bg, start: CGPoint(x: 0, y: rect.minY), end: CGPoint(x: 0, y: rect.maxY), options: [])
// Holztafel
let board = CGRect(x: rect.minX + 118 * s, y: rect.minY + 170 * s, width: rect.width - 236 * s, height: rect.height - 330 * s)
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -12 * s), blur: 20 * s, color: rgb(0x0B1F16, 0.45))
ctx.addPath(CGPath(roundedRect: board, cornerWidth: 34 * s, cornerHeight: 34 * s, transform: nil))
ctx.setFillColor(rgb(0xE6CC9C)); ctx.fillPath()
ctx.restoreGState()
// Maserung, ganz zart
ctx.saveGState()
ctx.addPath(CGPath(roundedRect: board, cornerWidth: 34 * s, cornerHeight: 34 * s, transform: nil))
ctx.clip()
ctx.setStrokeColor(rgb(0xC9A86A, 0.35)); ctx.setLineWidth(5 * s)
for i in 0..<7 {
let y = board.minY + CGFloat(i) * board.height / 6.5 + 18 * s
ctx.move(to: CGPoint(x: board.minX, y: y))
ctx.addCurve(to: CGPoint(x: board.maxX, y: y + 10 * s),
control1: CGPoint(x: board.minX + board.width * 0.35, y: y + 22 * s),
control2: CGPoint(x: board.minX + board.width * 0.65, y: y - 18 * s))
}
ctx.strokePath()
ctx.restoreGState()
// Leiste oben mit drei Haken
let rail = CGRect(x: board.minX + 40 * s, y: board.maxY - 96 * s, width: board.width - 80 * s, height: 22 * s)
ctx.addPath(CGPath(roundedRect: rail, cornerWidth: 11 * s, cornerHeight: 11 * s, transform: nil))
ctx.setFillColor(rgb(0xB89260)); ctx.fillPath()
// Namensschilder: hoch und schmal, wie in Japan üblich
let plateW = 128 * s, plateH = 330 * s, gap = 40 * s
let total = plateW * 3 + gap * 2
let x0 = board.midX - total / 2
let top = rail.minY - 20 * s
for i in 0..<3 {
let x = x0 + CGFloat(i) * (plateW + gap)
let plate = CGRect(x: x, y: top - plateH, width: plateW, height: plateH)
let onDuty = i == 1
// Haken
ctx.setFillColor(rgb(0x6B5135))
ctx.fillEllipse(in: CGRect(x: plate.midX - 13 * s, y: rail.midY - 13 * s, width: 26 * s, height: 26 * s))
ctx.setStrokeColor(rgb(0x6B5135)); ctx.setLineWidth(6 * s)
ctx.move(to: CGPoint(x: plate.midX, y: rail.midY)); ctx.addLine(to: CGPoint(x: plate.midX, y: plate.maxY - 10 * s)); ctx.strokePath()
// Schild
ctx.saveGState()
ctx.setShadow(offset: CGSize(width: 0, height: -6 * s), blur: 10 * s, color: rgb(0x3D2A14, 0.35))
ctx.addPath(CGPath(roundedRect: plate, cornerWidth: 18 * s, cornerHeight: 18 * s, transform: nil))
ctx.setFillColor(onDuty ? rgb(0xB5452B) : rgb(0xFBF6EC)); ctx.fillPath()
ctx.restoreGState()
// "Name": drei kurze Zeilen wie Schrift (bewusst ohne Zeichen, die als Symbol gelesen werden könnten)
ctx.setStrokeColor(onDuty ? rgb(0xFBF6EC) : rgb(0x2B2A27, 0.85)); ctx.setLineCap(.round); ctx.setLineWidth(16 * s)
let lines: [(CGFloat, CGFloat)] = [(0.30, 0.62), (0.45, 0.44), (0.60, 0.54)]
for (y, w) in lines {
let yy = plate.maxY - plate.height * y
ctx.move(to: CGPoint(x: plate.midX - plate.width * w / 2, y: yy))
ctx.addLine(to: CGPoint(x: plate.midX + plate.width * w / 2, y: yy))
}
ctx.strokePath()
}
ctx.restoreGState()
return ctx.makeImage()!
}
func png(_ img: CGImage, to url: URL) {
let rep = NSBitmapImageRep(cgImage: img)
try! rep.representation(using: .png, properties: [:])!.write(to: url)
}
let root = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let iconset = root.appendingPathComponent("build/AppIcon.iconset")
try? FileManager.default.removeItem(at: iconset)
try! FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)
for base in [16, 32, 128, 256, 512] {
png(drawIcon(size: CGFloat(base)), to: iconset.appendingPathComponent("icon_\(base)x\(base).png"))
png(drawIcon(size: CGFloat(base * 2)), to: iconset.appendingPathComponent("icon_\(base)x\(base)@2x.png"))
}
try? FileManager.default.createDirectory(at: root.appendingPathComponent("design"), withIntermediateDirectories: true)
png(drawIcon(size: 1024), to: root.appendingPathComponent("design/icon-1024.png"))
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/iconutil")
p.arguments = ["-c", "icns", iconset.path, "-o", root.appendingPathComponent("App/Resources/AppIcon.icns").path]
try! p.run(); p.waitUntilExit()
print(p.terminationStatus == 0 ? "AppIcon.icns geschrieben" : "iconutil fehlgeschlagen")

@ -0,0 +1,25 @@
#!/bin/bash
# Lädt DMG und Appcast auf den Update-Server. Ab dann bieten alle installierten
# Mawari-Kopien das Update beim nächsten Start (bzw. binnen eines Tages) an.
#
# Einmalig vorher den Ordner auf dem Server anlegen (siehe zielke-apps/README.md):
# ssh -t samuelzielke@10.0.5.223 'sudo install -d -o samuelzielke -g www-data -m 2775 /var/www/updates/mawari'
#
# Aufruf: bash Tools/publish.sh
set -euo pipefail
cd "$(dirname "$0")/.."
SERVER="${SERVER:-samuelzielke@10.0.5.223}"
DEST="/var/www/updates/mawari/"
VERSION=$(grep -m1 'MARKETING_VERSION:' project.yml | sed -E 's/.*MARKETING_VERSION: *"?([^"]+)"?/\1/')
DMG="build/Mawari-${VERSION}.dmg"
[ -f "$DMG" ] && [ -f build/appcast.xml ] || { echo "Fehler: erst Tools/build-dmg.sh und Tools/make-appcast.sh." >&2; exit 1; }
grep -q "Mawari-${VERSION}.dmg" build/appcast.xml || { echo "Fehler: appcast.xml passt nicht zu $VERSION." >&2; exit 1; }
cp "$DMG" build/Mawari-latest.dmg
# DMG zuerst, Appcast zuletzt – sonst zeigt der Appcast kurz auf eine fehlende Datei.
rsync -a --chmod=F644 "$DMG" build/Mawari-latest.dmg "$SERVER:$DEST"
rsync -a --chmod=F644 build/appcast.xml "$SERVER:$DEST"
echo "==> Veröffentlicht: https://update.zielke.studio/mawari/appcast.xml (Version $VERSION)"
curl -fsS "https://update.zielke.studio/mawari/appcast.xml" | grep -o "<sparkle:shortVersionString>[^<]*" || echo "Hinweis: Appcast noch nicht abrufbar (Proxy/DNS prüfen)."

@ -0,0 +1,29 @@
# Design
Leitbild: die hölzerne Diensttafel (*tōban-hyō*), auf der reihum (*mawari*) eingetragen wird, wer dran ist. Ruhig, hell, gut lesbar – die Tabelle ist das Produkt.
## Farben
Definiert in `App/Sources/Theme.swift`, jede Farbe mit heller und dunkler Fassung.
| Name | Hell | Dunkel | Verwendung |
|---|---|---|---|
| Tokiwa (常盤, immergrün) | `#2F6B4F` | `#6DBB93` | Markenfarbe, Bereich „Diener (Ernannte)“ |
| Kuri (栗, Kastanie) | `#7A5230` | `#D0A07A` | Bereich „Schulung“ |
| Kohaku (琥珀, Bernstein) | `#B45309` | `#F0A35A` | „nie“ – Person hatte die Aufgabe noch nie |
| Bengara (弁柄, Eisenrot) | `#B5452B` | `#E8775D` | Warnungen, Fehler |
| Line / Muted / Faint | – | – | Linien, Nebentext, „–“ (nicht freigegeben) |
Zu jeder Bereichsfarbe gibt es eine helle Fläche (`tokiwaSoft`, `kuriSoft`, `kohakuSoft`) für Kopfzeilen und Hervorhebungen.
## Symbol
`icon-1024.png` ist die Vorlage. Das Symbol wird nicht von Hand gezeichnet, sondern erzeugt:
```bash
swift Tools/make-icon.swift # schreibt design/icon-1024.png und App/Resources/AppIcon.icns
```
## Schrift und Texte
SF Pro (System), Zahlen und Daten in Monospaced-Ziffern. Texte auf Deutsch, kurz, Du-Form. Datum in der Übersicht als `TT.MM.JJ`, dazu „vor n Wochen“.

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

@ -0,0 +1,48 @@
# Datenformat
Mawari speichert alles in einer JSON-Datei:
```
~/Library/Application Support/Mawari/
├── daten.json aktueller Stand, wird nach jeder Änderung (300 ms verzögert) atomar geschrieben
└── Sicherungen/
├── daten-YYYY-MM-DD.json eine Kopie pro Tag beim Start, die letzten 60 bleiben
└── vor-import-<unixzeit>.json Stand vor jedem Import
```
Das Format ist identisch mit `leben-und-dienst.json` aus dem früheren HTML-Tool. Dateien lassen sich in beide Richtungen austauschen (*Ablage › Sicherung speichern / laden*).
## Aufbau
```json
{
"app": "leben-und-dienst-uebersicht",
"version": 1,
"updated": "2026-10-22T11:00:00Z",
"tasks": [
{ "id": "vorsitz", "name": "Vorsitz", "group": "diener", "withPartner": false },
{ "id": "gespraeche", "name": "Gespräche beginnen", "group": "schulung", "withPartner": true },
{ "id": "partner", "name": "Partner", "group": "schulung", "withPartner": false, "isPartner": true }
],
"persons": [
{ "id": "p1a2b3c4d", "name": "Max Muster", "gender": "", "note": "", "tasks": ["gebet", "bibellesung"] }
],
"entries": [
{ "id": "e5f6a7b8c", "date": "2026-10-16", "task": "gespraeche", "person": "p1a2b3c4d", "partner": "p9z8y7x6w", "note": "" }
]
}
```
| Feld | Bedeutung |
|---|---|
| `tasks[].group` | `diener` (Aufgaben für Ernannte) oder `schulung` |
| `tasks[].withPartner` | Eintrag hat ein Partner-Feld |
| `tasks[].isPartner` | Sonderspalte „Partner“: zeigt, wann jemand zuletzt **als Partner** dabei war (wird aus `entries[].partner` berechnet, nicht direkt eingetragen) |
| `persons[].tasks` | Aufgaben, für die die Person freigegeben ist. Nicht freigegeben = „–“ in der Übersicht |
| `entries[].date` | `YYYY-MM-DD`; Datum in der Zukunft wird als „geplant“ angezeigt |
## Toleranz beim Laden
Fehlende Felder werden mit Standardwerten ergänzt. Fehlt `tasks` oder ist die Liste leer, gelten die 15 Standardaufgaben aus `Database.defaultTasks` (`App/Sources/Model.swift`). Pflicht sind nur `persons` und `entries`.
Wird eine Person gelöscht, verschwinden ihre Einträge; wo sie Partner war, wird das Feld geleert.

@ -0,0 +1,78 @@
name: Mawari
options:
bundleIdPrefix: studio.zielke
deploymentTarget:
macOS: "14.0"
developmentLanguage: de
createIntermediateGroups: true
settings:
base:
SWIFT_VERSION: "6.0"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
MACOSX_DEPLOYMENT_TARGET: "14.0"
ARCHS: "arm64 x86_64"
DEAD_CODE_STRIPPING: YES
configs:
Debug:
ONLY_ACTIVE_ARCH: YES
Release:
ONLY_ACTIVE_ARCH: NO
packages:
# Selbstaktualisierung, gleiche Fassung wie drop.look und drop.two.
Sparkle:
url: https://github.com/sparkle-project/Sparkle
exactVersion: 2.10.0
targets:
Mawari:
type: application
platform: macOS
sources:
- path: App/Sources
# Das Symbol wird aus Tools/make-icon.swift erzeugt, nicht von Hand gezeichnet.
- path: App/Resources/AppIcon.icns
buildPhase: resources
dependencies:
- package: Sparkle
info:
# xcodegen ERZEUGT diese Datei aus den Angaben hier.
path: App/Info.plist
properties:
CFBundleName: Mawari
CFBundleDisplayName: Mawari
CFBundleIconFile: AppIcon
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
LSApplicationCategoryType: public.app-category.productivity
NSHumanReadableCopyright: © Zielke Studio
# --- Selbstaktualisierung (Sparkle) ---
SUFeedURL: https://update.zielke.studio/mawari/appcast.xml
# Öffentlicher EdDSA-Schlüssel, eigenes Paar für Mawari (Konto "mawari").
# Der private liegt im Schlüsselbund dieses Rechners – sichern!
SUPublicEDKey: MMz3ZcmIvAmxRd7hXMGFYq0E4wrwV8pMhUxnDcyc48I=
# Beim Start nach Updates suchen (Papa soll sich um nichts kümmern müssen).
SUEnableAutomaticChecks: YES
SUScheduledCheckInterval: 86400
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.mawari
PRODUCT_NAME: Mawari
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: "Developer ID Application"
DEVELOPMENT_TEAM: XY4C2RG3L5
ENABLE_HARDENED_RUNTIME: YES
CODE_SIGN_ENTITLEMENTS: App/Mawari.entitlements
schemes:
Mawari:
build:
targets:
Mawari: all
run:
config: Debug
archive:
config: Release
Loading…
Cancel
Save

Powered by TurnKey Linux.