瀏覽代碼

Add Reminders WebApp

* Add Reminders WebApp (macOS Reminders-style)

A self-contained ArozOS WebApp that mimics the macOS Reminders app.

Front-end (web/Reminders/index.html), single-page, no framework:
- Sidebar with the smart-list grid — Today, Scheduled, All, Flagged and a
  Completed card — plus custom "My Lists" with colour + SF-style icons and
  live incomplete counts, an Add List action and a light/dark toggle.
- Reminder rows with circle checkboxes, inline title editing, sub-tasks
  (Tab/Shift+Tab to indent), notes, due date/time chips (red when overdue),
  flags, priority (!/!!/!!!) and clickable URLs.
- Smart-view grouping: Today by time-of-day (All-Day/Morning/Afternoon/
  Tonight), Scheduled by date (with an Overdue group), All/Search by list.
- A detail editor popover (On a Day / At a Time, priority, list, URL, flag)
  and a list create/edit modal with colour and icon pickers.
- Native macOS look with full light/dark theming; sticky UI prefs via
  localStorage; responsive off-canvas sidebar on narrow screens.

Back-end AGI scripts (web/Reminders/backend/*.agi) persist a per-user
{ lists, reminders } store to user:/Document/Reminders/data.json via filelib,
following the Calendar app's pattern: init (load + seed default list),
saveReminder/deleteReminder, saveList/deleteList and a full saveData for
re-ordering and clearing completed items.

https://claude.ai/code/session_018JqHkXkuYjj5hz4kjiS1fQ

* Add CalDAV VTODO sync for Reminders and recurring support

Expose the Reminders web-app data as a CalDAV VTODO collection at
/caldav/{username}/reminders/ so iOS Reminders can sync bidirectionally
alongside the existing Calendar (VEVENT) collection. The principal now
advertises a calendar-home-set containing both collections.

Add RRULE recurrence support: events and reminders pass their recurrence
rule through ICS in both directions. The Reminders UI gains a Repeat
selector, shows a repeat indicator, and rolls recurring reminders forward
to their next occurrence on completion (matching iOS behaviour).

---------

Co-authored-by: Claude <noreply@anthropic.com>
Alan Yeung 6 天之前
父節點
當前提交
a5ef87f629

+ 176 - 62
src/mod/caldav/caldav.go

@@ -1,22 +1,32 @@
 package caldav
 
 /*
-	caldav.go - CalDAV server for ArozOS Calendar
+	caldav.go - CalDAV server for ArozOS Calendar and Reminders
 
 	Implements a subset of RFC 4791 (CalDAV) sufficient for bidirectional
-	sync with iOS Calendar.  Events are stored in the same JSON file used
-	by the Calendar web-app (user:/Document/Calendar/events.json) so both
-	interfaces share the same data without any migration.
+	sync with iOS Calendar and iOS Reminders.  Two calendar collections are
+	exposed under one principal:
+
+	  - "calendar"  : VEVENT components, backed by the Calendar web-app file
+	                  (user:/Document/Calendar/events.json)
+	  - "reminders" : VTODO components, backed by the Reminders web-app file
+	                  (user:/Document/Reminders/data.json)
+
+	Both share the same data files used by their web-apps, so the desktop
+	and iOS stay in sync without any migration.  Recurring events and
+	reminders are supported by passing RRULE through in both directions.
 
 	Authentication: HTTP Basic Auth where
 	  username = ArozOS username
 	  password = an ArozOS auto-login token for that user
 
 	URL layout:
-	  /caldav/                                  service root (principal discovery)
-	  /caldav/{username}/                       user principal
-	  /caldav/{username}/calendar/              calendar collection
-	  /caldav/{username}/calendar/{id}.ics      individual event resource
+	  /caldav/                                   service root (principal discovery)
+	  /caldav/{username}/                        user principal & calendar home
+	  /caldav/{username}/calendar/               event collection (VEVENT)
+	  /caldav/{username}/calendar/{id}.ics       individual event resource
+	  /caldav/{username}/reminders/              reminder collection (VTODO)
+	  /caldav/{username}/reminders/{id}.ics      individual reminder resource
 */
 
 import (
@@ -46,6 +56,7 @@ type CalendarEvent struct {
 	Notes    string         `json:"notes,omitempty"`
 	Reminder *EventReminder `json:"reminder,omitempty"`
 	Color    string         `json:"color,omitempty"`
+	RRule    string         `json:"rrule,omitempty"` // RFC 5545 recurrence rule, e.g. "FREQ=WEEKLY"
 }
 
 // EventReminder matches the reminder sub-object in events.json.
@@ -158,9 +169,11 @@ func (h *Handler) handlePropfind(w http.ResponseWriter, r *http.Request, path st
 			http.Error(w, "Not Found", http.StatusNotFound)
 			return
 		}
-		h.propfindPrincipal(w, username)
+		h.propfindPrincipal(w, username, depth)
 	case len(parts) == 2 && parts[1] == "calendar":
 		h.propfindCalendar(w, username, depth)
+	case len(parts) == 2 && parts[1] == "reminders":
+		h.propfindReminders(w, username, depth)
 	default:
 		http.Error(w, "Not Found", http.StatusNotFound)
 	}
@@ -183,24 +196,44 @@ func (h *Handler) propfindRoot(w http.ResponseWriter, username string) {
 	writeXML(w, body)
 }
 
-func (h *Handler) propfindPrincipal(w http.ResponseWriter, username string) {
+// propfindPrincipal answers PROPFIND on /caldav/{username}/.  This URL doubles
+// as both the user principal and the calendar-home-set; a Depth:1 request
+// enumerates the child calendar collections (events + reminders) so iOS can
+// discover both the Calendar and Reminders services from a single account.
+func (h *Handler) propfindPrincipal(w http.ResponseWriter, username string, depth string) {
 	principalHref := h.prefix + "/" + username + "/"
-	calHomeHref := h.prefix + "/" + username + "/calendar/"
-	body := xmlHeader() +
-		`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">` + "\n" +
-		`  <D:response>` + "\n" +
-		`    <D:href>` + principalHref + `</D:href>` + "\n" +
-		`    <D:propstat>` + "\n" +
-		`      <D:prop>` + "\n" +
-		`        <D:displayname>` + xmlEsc(username) + `</D:displayname>` + "\n" +
-		`        <D:principal-URL><D:href>` + principalHref + `</D:href></D:principal-URL>` + "\n" +
-		`        <C:calendar-home-set><D:href>` + calHomeHref + `</D:href></C:calendar-home-set>` + "\n" +
-		`      </D:prop>` + "\n" +
-		`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n" +
-		`    </D:propstat>` + "\n" +
-		`  </D:response>` + "\n" +
-		`</D:multistatus>`
-	writeXML(w, body)
+
+	var sb strings.Builder
+	sb.WriteString(xmlHeader())
+	sb.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">` + "\n")
+	sb.WriteString(`  <D:response>` + "\n")
+	sb.WriteString(`    <D:href>` + principalHref + `</D:href>` + "\n")
+	sb.WriteString(`    <D:propstat>` + "\n")
+	sb.WriteString(`      <D:prop>` + "\n")
+	sb.WriteString(`        <D:resourcetype><D:collection/><D:principal/></D:resourcetype>` + "\n")
+	sb.WriteString(`        <D:displayname>` + xmlEsc(username) + `</D:displayname>` + "\n")
+	sb.WriteString(`        <D:current-user-principal><D:href>` + principalHref + `</D:href></D:current-user-principal>` + "\n")
+	sb.WriteString(`        <D:principal-URL><D:href>` + principalHref + `</D:href></D:principal-URL>` + "\n")
+	sb.WriteString(`        <C:calendar-home-set><D:href>` + principalHref + `</D:href></C:calendar-home-set>` + "\n")
+	sb.WriteString(`      </D:prop>` + "\n")
+	sb.WriteString(`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n")
+	sb.WriteString(`    </D:propstat>` + "\n")
+	sb.WriteString(`  </D:response>` + "\n")
+
+	if depth != "0" {
+		// Enumerate the two calendar collections so clients see both.
+		if events, err := h.loadEvents(username); err == nil {
+			sb.WriteString(calendarCollectionResponse(
+				h.prefix+"/"+username+"/calendar/", "ArozOS Calendar", "VEVENT", collectionCTag(events)))
+		}
+		if reminders, err := h.loadReminders(username); err == nil {
+			sb.WriteString(calendarCollectionResponse(
+				h.prefix+"/"+username+"/reminders/", "ArozOS Reminders", "VTODO", remindersCTag(reminders)))
+		}
+	}
+
+	sb.WriteString(`</D:multistatus>`)
+	writeXML(w, sb.String())
 }
 
 func (h *Handler) propfindCalendar(w http.ResponseWriter, username string, depth string) {
@@ -212,23 +245,11 @@ func (h *Handler) propfindCalendar(w http.ResponseWriter, username string, depth
 	}
 
 	calHref := h.prefix + "/" + username + "/calendar/"
-	ctag := collectionCTag(events)
 
 	var sb strings.Builder
 	sb.WriteString(xmlHeader())
 	sb.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">` + "\n")
-	sb.WriteString(`  <D:response>` + "\n")
-	sb.WriteString(`    <D:href>` + calHref + `</D:href>` + "\n")
-	sb.WriteString(`    <D:propstat>` + "\n")
-	sb.WriteString(`      <D:prop>` + "\n")
-	sb.WriteString(`        <D:resourcetype><D:collection/><C:calendar/></D:resourcetype>` + "\n")
-	sb.WriteString(`        <D:displayname>ArozOS Calendar</D:displayname>` + "\n")
-	sb.WriteString(`        <C:supported-calendar-component-set><C:comp name="VEVENT"/></C:supported-calendar-component-set>` + "\n")
-	sb.WriteString(`        <CS:getctag>` + ctag + `</CS:getctag>` + "\n")
-	sb.WriteString(`      </D:prop>` + "\n")
-	sb.WriteString(`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n")
-	sb.WriteString(`    </D:propstat>` + "\n")
-	sb.WriteString(`  </D:response>` + "\n")
+	sb.WriteString(calendarCollectionResponse(calHref, "ArozOS Calendar", "VEVENT", collectionCTag(events)))
 
 	if depth != "0" {
 		for _, ev := range events {
@@ -240,6 +261,49 @@ func (h *Handler) propfindCalendar(w http.ResponseWriter, username string, depth
 	writeXML(w, sb.String())
 }
 
+// propfindReminders answers PROPFIND on the VTODO (reminders) collection.
+func (h *Handler) propfindReminders(w http.ResponseWriter, username string, depth string) {
+	reminders, err := h.loadReminders(username)
+	if err != nil {
+		logger.PrintAndLog("CalDAV", "load reminders for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	remHref := h.prefix + "/" + username + "/reminders/"
+
+	var sb strings.Builder
+	sb.WriteString(xmlHeader())
+	sb.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CS="http://calendarserver.org/ns/">` + "\n")
+	sb.WriteString(calendarCollectionResponse(remHref, "ArozOS Reminders", "VTODO", remindersCTag(reminders)))
+
+	if depth != "0" {
+		for _, rm := range reminders {
+			sb.WriteString(eventPropfindResponse(remHref+rm.ID+".ics", reminderETag(rm)))
+		}
+	}
+
+	sb.WriteString(`</D:multistatus>`)
+	writeXML(w, sb.String())
+}
+
+// calendarCollectionResponse renders the <D:response> for a calendar collection
+// advertising the given supported component (VEVENT or VTODO).
+func calendarCollectionResponse(href, displayName, compName, ctag string) string {
+	return `  <D:response>` + "\n" +
+		`    <D:href>` + href + `</D:href>` + "\n" +
+		`    <D:propstat>` + "\n" +
+		`      <D:prop>` + "\n" +
+		`        <D:resourcetype><D:collection/><C:calendar/></D:resourcetype>` + "\n" +
+		`        <D:displayname>` + xmlEsc(displayName) + `</D:displayname>` + "\n" +
+		`        <C:supported-calendar-component-set><C:comp name="` + compName + `"/></C:supported-calendar-component-set>` + "\n" +
+		`        <CS:getctag>` + ctag + `</CS:getctag>` + "\n" +
+		`      </D:prop>` + "\n" +
+		`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n" +
+		`    </D:propstat>` + "\n" +
+		`  </D:response>` + "\n"
+}
+
 func eventPropfindResponse(href, etag string) string {
 	return `  <D:response>` + "\n" +
 		`    <D:href>` + href + `</D:href>` + "\n" +
@@ -258,48 +322,71 @@ func eventPropfindResponse(href, etag string) string {
 
 func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, path string, username string) {
 	parts := splitURLPath(path)
-	if len(parts) < 2 || parts[1] != "calendar" {
+	if len(parts) < 2 {
 		http.Error(w, "Not Found", http.StatusNotFound)
 		return
 	}
-
-	body, err := io.ReadAll(r.Body)
-	if err != nil {
-		http.Error(w, "Bad Request", http.StatusBadRequest)
+	collection := parts[1]
+	if collection != "calendar" && collection != "reminders" {
+		http.Error(w, "Not Found", http.StatusNotFound)
 		return
 	}
 
-	events, err := h.loadEvents(username)
+	body, err := io.ReadAll(r.Body)
 	if err != nil {
-		logger.PrintAndLog("CalDAV", "load events for "+username, err)
-		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		http.Error(w, "Bad Request", http.StatusBadRequest)
 		return
 	}
-
-	calHref := h.prefix + "/" + username + "/calendar/"
 	bodyStr := string(body)
 
+	collHref := h.prefix + "/" + username + "/" + collection + "/"
+
 	// For calendar-multiget, only return the requested hrefs.
 	var filterIDs map[string]bool
 	if strings.Contains(bodyStr, "calendar-multiget") {
-		filterIDs = hrefsToIDSet(bodyStr, calHref)
+		filterIDs = hrefsToIDSet(bodyStr, collHref)
+	}
+
+	// (id, etag, ics) tuples for the requested collection.
+	type resource struct{ id, etag, ics string }
+	var resources []resource
+
+	if collection == "reminders" {
+		reminders, err := h.loadReminders(username)
+		if err != nil {
+			logger.PrintAndLog("CalDAV", "load reminders for "+username, err)
+			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+			return
+		}
+		for _, rm := range reminders {
+			resources = append(resources, resource{rm.ID, reminderETag(rm), reminderToICS(rm)})
+		}
+	} else {
+		events, err := h.loadEvents(username)
+		if err != nil {
+			logger.PrintAndLog("CalDAV", "load events for "+username, err)
+			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+			return
+		}
+		for _, ev := range events {
+			resources = append(resources, resource{ev.ID, eventETag(ev), eventToICS(ev)})
+		}
 	}
 
 	var sb strings.Builder
 	sb.WriteString(xmlHeader())
 	sb.WriteString(`<D:multistatus xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">` + "\n")
 
-	for _, ev := range events {
-		if filterIDs != nil && !filterIDs[ev.ID] {
+	for _, res := range resources {
+		if filterIDs != nil && !filterIDs[res.id] {
 			continue
 		}
-		icsData := eventToICS(ev)
 		sb.WriteString(`  <D:response>` + "\n")
-		sb.WriteString(`    <D:href>` + calHref + ev.ID + `.ics</D:href>` + "\n")
+		sb.WriteString(`    <D:href>` + collHref + res.id + `.ics</D:href>` + "\n")
 		sb.WriteString(`    <D:propstat>` + "\n")
 		sb.WriteString(`      <D:prop>` + "\n")
-		sb.WriteString(`        <D:getetag>` + eventETag(ev) + `</D:getetag>` + "\n")
-		sb.WriteString(`        <C:calendar-data>` + xmlEsc(icsData) + `</C:calendar-data>` + "\n")
+		sb.WriteString(`        <D:getetag>` + res.etag + `</D:getetag>` + "\n")
+		sb.WriteString(`        <C:calendar-data>` + xmlEsc(res.ics) + `</C:calendar-data>` + "\n")
 		sb.WriteString(`      </D:prop>` + "\n")
 		sb.WriteString(`      <D:status>HTTP/1.1 200 OK</D:status>` + "\n")
 		sb.WriteString(`    </D:propstat>` + "\n")
@@ -313,11 +400,15 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request, path stri
 // ── GET / HEAD ────────────────────────────────────────────────────────────────
 
 func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request, path string, username string) {
-	eventID := extractEventID(path)
+	collection, eventID := parseResourcePath(path)
 	if eventID == "" {
 		http.Error(w, "Not Found", http.StatusNotFound)
 		return
 	}
+	if collection == "reminders" {
+		h.handleGetReminder(w, r, eventID, username)
+		return
+	}
 
 	events, err := h.loadEvents(username)
 	if err != nil {
@@ -345,7 +436,7 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request, path string,
 // ── PUT ───────────────────────────────────────────────────────────────────────
 
 func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request, path string, username string) {
-	eventID := extractEventID(path)
+	collection, eventID := parseResourcePath(path)
 	if eventID == "" {
 		http.Error(w, "Bad Request", http.StatusBadRequest)
 		return
@@ -357,6 +448,11 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request, path string,
 		return
 	}
 
+	if collection == "reminders" {
+		h.handlePutReminder(w, string(body), eventID, username)
+		return
+	}
+
 	newEv, err := icsToEvent(string(body), eventID)
 	if err != nil || newEv.Title == "" {
 		logger.PrintAndLog("CalDAV", "PUT: ICS parse failed for "+eventID, err)
@@ -404,11 +500,15 @@ func (h *Handler) handlePut(w http.ResponseWriter, r *http.Request, path string,
 // ── DELETE ────────────────────────────────────────────────────────────────────
 
 func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request, path string, username string) {
-	eventID := extractEventID(path)
+	collection, eventID := parseResourcePath(path)
 	if eventID == "" {
 		http.Error(w, "Bad Request", http.StatusBadRequest)
 		return
 	}
+	if collection == "reminders" {
+		h.handleDeleteReminder(w, eventID, username)
+		return
+	}
 
 	h.mu.Lock()
 	defer h.mu.Unlock()
@@ -527,11 +627,25 @@ func splitURLPath(path string) []string {
 // extractEventID returns the event ID encoded in a path like
 // /{username}/calendar/{id}.ics, or "" if the path is not an event URL.
 func extractEventID(path string) string {
-	parts := splitURLPath(path)
-	if len(parts) != 3 || parts[1] != "calendar" {
+	collection, id := parseResourcePath(path)
+	if collection != "calendar" {
 		return ""
 	}
-	return strings.TrimSuffix(parts[2], ".ics")
+	return id
+}
+
+// parseResourcePath splits a resource URL such as /{username}/{collection}/{id}.ics
+// into its collection ("calendar" or "reminders") and resource ID.  Both are
+// returned empty when the path does not address a known resource.
+func parseResourcePath(path string) (collection string, id string) {
+	parts := splitURLPath(path)
+	if len(parts) != 3 {
+		return "", ""
+	}
+	if parts[1] != "calendar" && parts[1] != "reminders" {
+		return "", ""
+	}
+	return parts[1], strings.TrimSuffix(parts[2], ".ics")
 }
 
 // hrefsToIDSet parses href elements from a calendar-multiget body and

+ 22 - 0
src/mod/caldav/ics.go

@@ -50,6 +50,9 @@ func eventToICS(ev CalendarEvent) string {
 			sb.WriteString("X-APPLE-CALENDAR-COLOR:" + hex + "\r\n")
 		}
 	}
+	if rrule := normalizeRRule(ev.RRule); rrule != "" {
+		sb.WriteString("RRULE:" + rrule + "\r\n")
+	}
 	if ev.Reminder != nil {
 		trigger := reminderToTrigger(ev.Reminder)
 		sb.WriteString("BEGIN:VALARM\r\n")
@@ -131,6 +134,8 @@ func icsToEvent(icsData string, idHint string) (CalendarEvent, error) {
 		case "DTEND":
 			t, _ := parseICSDateTime(key, val)
 			ev.End = t.UnixMilli()
+		case "RRULE":
+			ev.RRule = normalizeRRule(strings.TrimSpace(val))
 		}
 	}
 
@@ -331,6 +336,23 @@ func triggerToReminder(trigger string) *EventReminder {
 	return nil
 }
 
+// normalizeRRule sanitises a recurrence rule for safe inclusion in an ICS
+// property line.  It strips a redundant leading "RRULE:" prefix, removes any
+// embedded line breaks, and trims surrounding whitespace.  The rule is passed
+// through verbatim otherwise so any valid RFC 5545 RRULE survives a round trip.
+func normalizeRRule(rrule string) string {
+	rrule = strings.TrimSpace(rrule)
+	if rrule == "" {
+		return ""
+	}
+	if strings.HasPrefix(strings.ToUpper(rrule), "RRULE:") {
+		rrule = rrule[len("RRULE:"):]
+	}
+	rrule = strings.ReplaceAll(rrule, "\r", "")
+	rrule = strings.ReplaceAll(rrule, "\n", "")
+	return strings.TrimSpace(rrule)
+}
+
 // eventETag returns a quoted MD5 ETag for the given event.
 func eventETag(ev CalendarEvent) string {
 	data, _ := json.Marshal(ev)

+ 436 - 0
src/mod/caldav/todo.go

@@ -0,0 +1,436 @@
+package caldav
+
+/*
+	todo.go - VTODO (reminders) support for the CalDAV server
+
+	Exposes the ArozOS Reminders web-app data (user:/Document/Reminders/data.json)
+	as a CalDAV calendar collection of VTODO components so iOS Reminders can sync
+	bidirectionally.  Recurring reminders are supported by passing RRULE through
+	in both directions.
+
+	The data file is shared with the Reminders web-app and has the shape
+	{ "lists": [...], "reminders": [...] }; only the reminders array is touched
+	by CalDAV writes, and unknown list data is preserved untouched.
+*/
+
+import (
+	"crypto/md5"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+
+	"imuslab.com/arozos/mod/info/logger"
+)
+
+// ReminderItem mirrors a single reminder object in the Reminders data.json.
+type ReminderItem struct {
+	ID          string `json:"id"`
+	ListID      string `json:"listId"`
+	ParentID    string `json:"parentId,omitempty"`
+	Title       string `json:"title"`
+	Notes       string `json:"notes,omitempty"`
+	Completed   bool   `json:"completed"`
+	CompletedAt int64  `json:"completedAt,omitempty"`
+	Flagged     bool   `json:"flagged,omitempty"`
+	Priority    int    `json:"priority"`          // 0=None 1=Low 2=Medium 3=High
+	DueDate     string `json:"dueDate,omitempty"` // YYYY-MM-DD
+	DueTime     string `json:"dueTime,omitempty"` // HH:MM
+	URL         string `json:"url,omitempty"`
+	CreatedAt   int64  `json:"createdAt,omitempty"`
+	Order       int64  `json:"order,omitempty"`
+	RRule       string `json:"rrule,omitempty"` // RFC 5545 recurrence rule, e.g. "FREQ=DAILY"
+}
+
+// reminderList mirrors a list object; kept opaque so it round-trips untouched.
+type reminderList struct {
+	ID    string `json:"id"`
+	Name  string `json:"name"`
+	Color string `json:"color,omitempty"`
+	Icon  string `json:"icon,omitempty"`
+	Order int64  `json:"order,omitempty"`
+}
+
+// reminderStore is the on-disk shape of data.json.
+type reminderStore struct {
+	Lists     []reminderList `json:"lists"`
+	Reminders []ReminderItem `json:"reminders"`
+}
+
+// ── Storage helpers ───────────────────────────────────────────────────────────
+
+func (h *Handler) remindersFilePath(username string) (string, error) {
+	userObj, err := h.userHandler.GetUserInfoFromUsername(username)
+	if err != nil {
+		return "", err
+	}
+	fsh, err := userObj.GetHomeFileSystemHandler()
+	if err != nil {
+		return "", err
+	}
+	return fsh.FileSystemAbstraction.VirtualPathToRealPath("/Document/Reminders/data.json", username)
+}
+
+func (h *Handler) loadReminderStore(username string) (reminderStore, error) {
+	store := reminderStore{Lists: []reminderList{}, Reminders: []ReminderItem{}}
+	p, err := h.remindersFilePath(username)
+	if err != nil {
+		return store, err
+	}
+	data, err := os.ReadFile(p)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return store, nil
+		}
+		return store, err
+	}
+	if err := json.Unmarshal(data, &store); err != nil {
+		return store, err
+	}
+	return store, nil
+}
+
+func (h *Handler) loadReminders(username string) ([]ReminderItem, error) {
+	store, err := h.loadReminderStore(username)
+	if err != nil {
+		return nil, err
+	}
+	return store.Reminders, nil
+}
+
+func (h *Handler) saveReminderStore(username string, store reminderStore) error {
+	p, err := h.remindersFilePath(username)
+	if err != nil {
+		return err
+	}
+	if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
+		return err
+	}
+	if store.Lists == nil {
+		store.Lists = []reminderList{}
+	}
+	if store.Reminders == nil {
+		store.Reminders = []ReminderItem{}
+	}
+	data, err := json.MarshalIndent(store, "", "  ")
+	if err != nil {
+		return err
+	}
+	return os.WriteFile(p, data, 0644)
+}
+
+// defaultListID returns the list a CalDAV-created reminder should belong to:
+// the first existing list, or "ls_default" to match the web-app's seed list.
+func defaultListID(store reminderStore) string {
+	if len(store.Lists) > 0 {
+		return store.Lists[0].ID
+	}
+	return "ls_default"
+}
+
+// ── HTTP handlers (called from caldav.go dispatch) ─────────────────────────────
+
+func (h *Handler) handleGetReminder(w http.ResponseWriter, r *http.Request, id string, username string) {
+	reminders, err := h.loadReminders(username)
+	if err != nil {
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+	for _, rm := range reminders {
+		if rm.ID == id {
+			ics := reminderToICS(rm)
+			w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
+			w.Header().Set("ETag", reminderETag(rm))
+			if r.Method == http.MethodHead {
+				w.WriteHeader(http.StatusOK)
+				return
+			}
+			w.WriteHeader(http.StatusOK)
+			fmt.Fprint(w, ics)
+			return
+		}
+	}
+	http.Error(w, "Not Found", http.StatusNotFound)
+}
+
+func (h *Handler) handlePutReminder(w http.ResponseWriter, body string, id string, username string) {
+	newRm, err := icsToReminder(body, id)
+	if err != nil || newRm.Title == "" {
+		logger.PrintAndLog("CalDAV", "PUT: VTODO parse failed for "+id, err)
+		http.Error(w, "Bad Request: cannot parse VTODO", http.StatusBadRequest)
+		return
+	}
+	newRm.ID = id
+
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	store, err := h.loadReminderStore(username)
+	if err != nil {
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	isUpdate := false
+	for i, rm := range store.Reminders {
+		if rm.ID == id {
+			// Preserve fields that VTODO does not carry (list membership,
+			// hierarchy, creation/order metadata) across the update.
+			newRm.ListID = rm.ListID
+			newRm.ParentID = rm.ParentID
+			newRm.CreatedAt = rm.CreatedAt
+			newRm.Order = rm.Order
+			store.Reminders[i] = newRm
+			isUpdate = true
+			break
+		}
+	}
+	if !isUpdate {
+		newRm.ListID = defaultListID(store)
+		newRm.CreatedAt = time.Now().UnixMilli()
+		newRm.Order = newRm.CreatedAt
+		store.Reminders = append(store.Reminders, newRm)
+	}
+
+	if err := h.saveReminderStore(username, store); err != nil {
+		logger.PrintAndLog("CalDAV", "save reminders for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	w.Header().Set("ETag", reminderETag(newRm))
+	if isUpdate {
+		w.WriteHeader(http.StatusNoContent)
+	} else {
+		w.WriteHeader(http.StatusCreated)
+	}
+}
+
+func (h *Handler) handleDeleteReminder(w http.ResponseWriter, id string, username string) {
+	h.mu.Lock()
+	defer h.mu.Unlock()
+
+	store, err := h.loadReminderStore(username)
+	if err != nil {
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+
+	kept := make([]ReminderItem, 0, len(store.Reminders))
+	found := false
+	for _, rm := range store.Reminders {
+		// Deleting a reminder also removes its sub-tasks, mirroring the web-app.
+		if rm.ID == id {
+			found = true
+			continue
+		}
+		if rm.ParentID == id {
+			continue
+		}
+		kept = append(kept, rm)
+	}
+	if !found {
+		http.Error(w, "Not Found", http.StatusNotFound)
+		return
+	}
+	store.Reminders = kept
+
+	if err := h.saveReminderStore(username, store); err != nil {
+		logger.PrintAndLog("CalDAV", "save reminders for "+username, err)
+		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
+		return
+	}
+	w.WriteHeader(http.StatusNoContent)
+}
+
+// ── VTODO conversion ───────────────────────────────────────────────────────────
+
+// reminderToICS serialises a ReminderItem as a VCALENDAR / VTODO string.
+func reminderToICS(rm ReminderItem) string {
+	var sb strings.Builder
+	sb.WriteString("BEGIN:VCALENDAR\r\n")
+	sb.WriteString("VERSION:2.0\r\n")
+	sb.WriteString("PRODID:-//ArozOS//CalDAV//EN\r\n")
+	sb.WriteString("BEGIN:VTODO\r\n")
+	sb.WriteString("UID:" + rm.ID + "@arozos\r\n")
+	sb.WriteString("SUMMARY:" + escapeICSText(rm.Title) + "\r\n")
+
+	if rm.Notes != "" {
+		sb.WriteString("DESCRIPTION:" + escapeICSText(rm.Notes) + "\r\n")
+	}
+	if rm.DueDate != "" {
+		sb.WriteString(reminderDueToICS(rm.DueDate, rm.DueTime) + "\r\n")
+	}
+	if prio := arozPriorityToICS(rm.Priority); prio > 0 {
+		sb.WriteString("PRIORITY:" + strconv.Itoa(prio) + "\r\n")
+	}
+	if rm.Completed {
+		sb.WriteString("STATUS:COMPLETED\r\n")
+		sb.WriteString("PERCENT-COMPLETE:100\r\n")
+		if rm.CompletedAt > 0 {
+			sb.WriteString("COMPLETED:" + time.UnixMilli(rm.CompletedAt).UTC().Format("20060102T150405Z") + "\r\n")
+		}
+	} else {
+		sb.WriteString("STATUS:NEEDS-ACTION\r\n")
+	}
+	if rm.URL != "" {
+		sb.WriteString("URL:" + escapeICSText(rm.URL) + "\r\n")
+	}
+	if rm.ParentID != "" {
+		sb.WriteString("RELATED-TO:" + rm.ParentID + "@arozos\r\n")
+	}
+	if rrule := normalizeRRule(rm.RRule); rrule != "" {
+		sb.WriteString("RRULE:" + rrule + "\r\n")
+	}
+
+	sb.WriteString("END:VTODO\r\n")
+	sb.WriteString("END:VCALENDAR\r\n")
+	return sb.String()
+}
+
+// icsToReminder parses a VCALENDAR string containing a VTODO into a ReminderItem.
+// idHint is used as the reminder ID when the UID is absent or needs normalising.
+func icsToReminder(icsData string, idHint string) (ReminderItem, error) {
+	lines := unfoldICSLines(icsData)
+
+	rm := ReminderItem{ID: idHint}
+	inVTodo := false
+
+	for _, line := range lines {
+		switch strings.ToUpper(line) {
+		case "BEGIN:VTODO":
+			inVTodo = true
+			continue
+		case "END:VTODO":
+			inVTodo = false
+			continue
+		}
+		if !inVTodo {
+			continue
+		}
+
+		key, val := splitICSLine(line)
+		baseKey := strings.ToUpper(strings.Split(key, ";")[0])
+
+		switch baseKey {
+		case "UID":
+			uid := unescapeICSText(strings.TrimSpace(val))
+			uid = strings.TrimSuffix(uid, "@arozos")
+			if uid != "" {
+				rm.ID = uid
+			}
+		case "SUMMARY":
+			rm.Title = unescapeICSText(val)
+		case "DESCRIPTION":
+			rm.Notes = unescapeICSText(val)
+		case "DUE":
+			rm.DueDate, rm.DueTime = parseICSDue(key, val)
+		case "PRIORITY":
+			if v, err := strconv.Atoi(strings.TrimSpace(val)); err == nil {
+				rm.Priority = icsPriorityToAroz(v)
+			}
+		case "STATUS":
+			rm.Completed = strings.EqualFold(strings.TrimSpace(val), "COMPLETED")
+		case "COMPLETED":
+			if t, _ := parseICSDateTime(key, val); !t.IsZero() {
+				rm.CompletedAt = t.UnixMilli()
+			}
+		case "PERCENT-COMPLETE":
+			if v, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && v >= 100 {
+				rm.Completed = true
+			}
+		case "URL":
+			rm.URL = unescapeICSText(val)
+		case "RELATED-TO":
+			rm.ParentID = strings.TrimSuffix(unescapeICSText(strings.TrimSpace(val)), "@arozos")
+		case "RRULE":
+			rm.RRule = normalizeRRule(strings.TrimSpace(val))
+		}
+	}
+
+	if rm.Completed && rm.CompletedAt == 0 {
+		rm.CompletedAt = time.Now().UnixMilli()
+	}
+
+	return rm, nil
+}
+
+// reminderDueToICS formats a reminder due date/time as a DUE property value.
+// Reminders use floating local time (no timezone) so the literal wall-clock
+// time set on the desktop matches what iOS shows and vice-versa.
+func reminderDueToICS(dueDate, dueTime string) string {
+	d := strings.ReplaceAll(dueDate, "-", "")
+	if dueTime == "" {
+		return "DUE;VALUE=DATE:" + d
+	}
+	t := strings.ReplaceAll(dueTime, ":", "")
+	return "DUE:" + d + "T" + t + "00"
+}
+
+// parseICSDue extracts a reminder's date (YYYY-MM-DD) and time (HH:MM) from a
+// DUE property, treating the value as floating local time.  An all-day DUE
+// (VALUE=DATE) yields an empty time.
+func parseICSDue(key, val string) (dueDate, dueTime string) {
+	val = strings.TrimSpace(val)
+	val = strings.TrimSuffix(val, "Z") // ignore UTC designator; treat as wall-clock
+	if len(val) >= 8 {
+		dueDate = val[:4] + "-" + val[4:6] + "-" + val[6:8]
+	}
+	if strings.Contains(strings.ToUpper(key), "VALUE=DATE") {
+		return dueDate, ""
+	}
+	if idx := strings.Index(val, "T"); idx >= 0 {
+		t := val[idx+1:]
+		if len(t) >= 4 {
+			dueTime = t[:2] + ":" + t[2:4]
+		}
+	}
+	return dueDate, dueTime
+}
+
+// arozPriorityToICS maps the ArozOS priority (0..3) to an iCalendar PRIORITY
+// (1=high .. 9=low, 0=undefined) using the values iOS understands.
+func arozPriorityToICS(p int) int {
+	switch p {
+	case 3: // High
+		return 1
+	case 2: // Medium
+		return 5
+	case 1: // Low
+		return 9
+	default: // None
+		return 0
+	}
+}
+
+// icsPriorityToAroz maps an iCalendar PRIORITY back to the ArozOS scale.
+func icsPriorityToAroz(p int) int {
+	switch {
+	case p <= 0:
+		return 0 // None / undefined
+	case p <= 4:
+		return 3 // High
+	case p == 5:
+		return 2 // Medium
+	default:
+		return 1 // Low (6..9)
+	}
+}
+
+// reminderETag returns a quoted MD5 ETag for the given reminder.
+func reminderETag(rm ReminderItem) string {
+	data, _ := json.Marshal(rm)
+	h := md5.Sum(data)
+	return fmt.Sprintf(`"%x"`, h)
+}
+
+// remindersCTag returns an unquoted MD5 sync token for the whole collection.
+func remindersCTag(reminders []ReminderItem) string {
+	data, _ := json.Marshal(reminders)
+	h := md5.Sum(data)
+	return fmt.Sprintf("%x", h)
+}

+ 228 - 0
src/mod/caldav/todo_test.go

@@ -0,0 +1,228 @@
+package caldav
+
+import (
+	"strings"
+	"testing"
+	"time"
+)
+
+func TestReminderToICS_BasicFields(t *testing.T) {
+	rm := ReminderItem{
+		ID:       "rm_test1",
+		Title:    "Buy milk",
+		Notes:    "Two cartons",
+		Priority: 3, // High
+		DueDate:  "2024-03-15",
+		DueTime:  "14:30",
+		URL:      "https://example.com",
+	}
+	ics := reminderToICS(rm)
+
+	checks := []string{
+		"BEGIN:VCALENDAR",
+		"BEGIN:VTODO",
+		"UID:rm_test1@arozos",
+		"SUMMARY:Buy milk",
+		"DESCRIPTION:Two cartons",
+		"DUE:20240315T143000",
+		"PRIORITY:1", // High -> 1
+		"STATUS:NEEDS-ACTION",
+		"URL:https://example.com",
+		"END:VTODO",
+		"END:VCALENDAR",
+	}
+	for _, want := range checks {
+		if !strings.Contains(ics, want) {
+			t.Errorf("reminderToICS: missing %q in output:\n%s", want, ics)
+		}
+	}
+}
+
+func TestReminderToICS_AllDayDue(t *testing.T) {
+	rm := ReminderItem{ID: "rm_ad", Title: "Pay rent", DueDate: "2024-04-01"}
+	ics := reminderToICS(rm)
+	if !strings.Contains(ics, "DUE;VALUE=DATE:20240401") {
+		t.Errorf("expected date-only DUE, got:\n%s", ics)
+	}
+}
+
+func TestReminderToICS_Completed(t *testing.T) {
+	completedAt := time.Date(2024, 3, 16, 9, 0, 0, 0, time.UTC).UnixMilli()
+	rm := ReminderItem{ID: "rm_done", Title: "Done", Completed: true, CompletedAt: completedAt}
+	ics := reminderToICS(rm)
+	for _, want := range []string{"STATUS:COMPLETED", "PERCENT-COMPLETE:100", "COMPLETED:20240316T090000Z"} {
+		if !strings.Contains(ics, want) {
+			t.Errorf("reminderToICS completed: missing %q in:\n%s", want, ics)
+		}
+	}
+}
+
+func TestReminderToICS_Recurring(t *testing.T) {
+	rm := ReminderItem{ID: "rm_rec", Title: "Standup", DueDate: "2024-03-15", RRule: "FREQ=WEEKLY"}
+	ics := reminderToICS(rm)
+	if !strings.Contains(ics, "RRULE:FREQ=WEEKLY") {
+		t.Errorf("expected RRULE in recurring reminder, got:\n%s", ics)
+	}
+}
+
+func TestICSToReminder_RoundTrip(t *testing.T) {
+	original := ReminderItem{
+		ID:       "rm_rt",
+		Title:    "Round trip",
+		Notes:    "some notes",
+		Priority: 2, // Medium
+		DueDate:  "2024-06-01",
+		DueTime:  "08:15",
+		URL:      "https://example.org",
+		RRule:    "FREQ=DAILY;INTERVAL=2",
+	}
+	ics := reminderToICS(original)
+	parsed, err := icsToReminder(ics, "rm_rt")
+	if err != nil {
+		t.Fatalf("icsToReminder error: %v", err)
+	}
+	if parsed.Title != original.Title {
+		t.Errorf("Title: got %q want %q", parsed.Title, original.Title)
+	}
+	if parsed.Notes != original.Notes {
+		t.Errorf("Notes: got %q want %q", parsed.Notes, original.Notes)
+	}
+	if parsed.Priority != original.Priority {
+		t.Errorf("Priority: got %d want %d", parsed.Priority, original.Priority)
+	}
+	if parsed.DueDate != original.DueDate {
+		t.Errorf("DueDate: got %q want %q", parsed.DueDate, original.DueDate)
+	}
+	if parsed.DueTime != original.DueTime {
+		t.Errorf("DueTime: got %q want %q", parsed.DueTime, original.DueTime)
+	}
+	if parsed.URL != original.URL {
+		t.Errorf("URL: got %q want %q", parsed.URL, original.URL)
+	}
+	if parsed.RRule != original.RRule {
+		t.Errorf("RRule: got %q want %q", parsed.RRule, original.RRule)
+	}
+}
+
+func TestICSToReminder_iOSCompleted(t *testing.T) {
+	ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\n" +
+		"UID:ios-task@icloud.com\r\n" +
+		"SUMMARY:Finish report\r\n" +
+		"STATUS:COMPLETED\r\n" +
+		"PERCENT-COMPLETE:100\r\n" +
+		"COMPLETED:20240320T120000Z\r\n" +
+		"END:VTODO\r\nEND:VCALENDAR\r\n"
+	rm, err := icsToReminder(ics, "rm_url")
+	if err != nil {
+		t.Fatalf("icsToReminder error: %v", err)
+	}
+	if !rm.Completed {
+		t.Error("expected reminder to be completed")
+	}
+	if rm.CompletedAt == 0 {
+		t.Error("expected CompletedAt to be set")
+	}
+	if rm.Title != "Finish report" {
+		t.Errorf("Title: got %q", rm.Title)
+	}
+}
+
+func TestParseICSDue(t *testing.T) {
+	cases := []struct {
+		key, val    string
+		wantDate    string
+		wantTime    string
+		description string
+	}{
+		{"DUE", "20240315T143000", "2024-03-15", "14:30", "floating datetime"},
+		{"DUE", "20240315T143000Z", "2024-03-15", "14:30", "utc datetime treated as wall-clock"},
+		{"DUE;VALUE=DATE", "20240401", "2024-04-01", "", "all-day"},
+	}
+	for _, tc := range cases {
+		d, tm := parseICSDue(tc.key, tc.val)
+		if d != tc.wantDate || tm != tc.wantTime {
+			t.Errorf("parseICSDue(%s) [%s]: got (%q,%q) want (%q,%q)", tc.val, tc.description, d, tm, tc.wantDate, tc.wantTime)
+		}
+	}
+}
+
+func TestPriorityMapping(t *testing.T) {
+	// ArozOS -> ICS -> ArozOS should be stable for the four canonical levels.
+	for _, p := range []int{0, 1, 2, 3} {
+		ics := arozPriorityToICS(p)
+		back := icsPriorityToAroz(ics)
+		if back != p {
+			t.Errorf("priority round trip: %d -> ICS %d -> %d", p, ics, back)
+		}
+	}
+	// Spot-check the iOS-facing values.
+	if arozPriorityToICS(3) != 1 {
+		t.Errorf("High should map to ICS PRIORITY 1, got %d", arozPriorityToICS(3))
+	}
+	if icsPriorityToAroz(5) != 2 {
+		t.Errorf("ICS PRIORITY 5 should map to Medium, got %d", icsPriorityToAroz(5))
+	}
+}
+
+func TestRemindersCTag_ChangesWithData(t *testing.T) {
+	a := []ReminderItem{{ID: "a", Title: "A"}}
+	b := []ReminderItem{{ID: "a", Title: "A"}, {ID: "b", Title: "B"}}
+	if remindersCTag(a) == remindersCTag(b) {
+		t.Error("remindersCTag should differ when reminders differ")
+	}
+}
+
+func TestParseResourcePath(t *testing.T) {
+	cases := []struct {
+		path     string
+		wantColl string
+		wantID   string
+	}{
+		{"/alice/calendar/ev1.ics", "calendar", "ev1"},
+		{"/alice/reminders/rm1.ics", "reminders", "rm1"},
+		{"/alice/reminders/", "", ""},
+		{"/alice/", "", ""},
+		{"/alice/unknown/x.ics", "", ""},
+	}
+	for _, tc := range cases {
+		coll, id := parseResourcePath(tc.path)
+		if coll != tc.wantColl || id != tc.wantID {
+			t.Errorf("parseResourcePath(%q): got (%q,%q) want (%q,%q)", tc.path, coll, id, tc.wantColl, tc.wantID)
+		}
+	}
+}
+
+func TestNormalizeRRule(t *testing.T) {
+	cases := []struct{ in, want string }{
+		{"FREQ=DAILY", "FREQ=DAILY"},
+		{"RRULE:FREQ=WEEKLY", "FREQ=WEEKLY"},
+		{"  FREQ=MONTHLY\r\n", "FREQ=MONTHLY"},
+		{"", ""},
+	}
+	for _, tc := range cases {
+		if got := normalizeRRule(tc.in); got != tc.want {
+			t.Errorf("normalizeRRule(%q): got %q want %q", tc.in, got, tc.want)
+		}
+	}
+}
+
+func TestEventToICS_Recurring(t *testing.T) {
+	ev := CalendarEvent{
+		ID:    "ev_rec",
+		Title: "Weekly sync",
+		Start: time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC).UnixMilli(),
+		End:   time.Date(2024, 3, 15, 11, 0, 0, 0, time.UTC).UnixMilli(),
+		RRule: "FREQ=WEEKLY;BYDAY=MO",
+	}
+	ics := eventToICS(ev)
+	if !strings.Contains(ics, "RRULE:FREQ=WEEKLY;BYDAY=MO") {
+		t.Errorf("expected RRULE in recurring event, got:\n%s", ics)
+	}
+	parsed, err := icsToEvent(ics, "ev_rec")
+	if err != nil {
+		t.Fatalf("icsToEvent error: %v", err)
+	}
+	if parsed.RRule != "FREQ=WEEKLY;BYDAY=MO" {
+		t.Errorf("event RRule round trip: got %q", parsed.RRule)
+	}
+}

+ 33 - 0
src/web/Reminders/backend/deleteList.agi

@@ -0,0 +1,33 @@
+/*
+    Reminders - Delete a list by id (also removes every reminder in that list).
+    POST: listId (string)
+*/
+requirelib("filelib");
+
+var dataPath = "user:/Document/Reminders/data.json";
+
+if (!listId || listId === "") {
+    sendJSONResp({ error: "Missing listId" });
+} else {
+    var data = { lists: [], reminders: [] };
+    if (filelib.fileExists(dataPath)) {
+        try {
+            var parsed = JSON.parse(filelib.readFile(dataPath));
+            if (parsed && Array.isArray(parsed.lists) && Array.isArray(parsed.reminders)) data = parsed;
+        } catch (e) {}
+    }
+
+    var lists = [];
+    for (var i = 0; i < data.lists.length; i++) {
+        if (data.lists[i].id !== listId) lists.push(data.lists[i]);
+    }
+    var reminders = [];
+    for (var j = 0; j < data.reminders.length; j++) {
+        if (data.reminders[j].listId !== listId) reminders.push(data.reminders[j]);
+    }
+    data.lists = lists;
+    data.reminders = reminders;
+
+    filelib.writeFile(dataPath, JSON.stringify(data));
+    sendJSONResp({ ok: true });
+}

+ 31 - 0
src/web/Reminders/backend/deleteReminder.agi

@@ -0,0 +1,31 @@
+/*
+    Reminders - Delete a reminder by id (also removes any of its sub-tasks).
+    POST: reminderId (string)
+*/
+requirelib("filelib");
+
+var dataPath = "user:/Document/Reminders/data.json";
+
+if (!reminderId || reminderId === "") {
+    sendJSONResp({ error: "Missing reminderId" });
+} else {
+    var data = { lists: [], reminders: [] };
+    if (filelib.fileExists(dataPath)) {
+        try {
+            var parsed = JSON.parse(filelib.readFile(dataPath));
+            if (parsed && Array.isArray(parsed.lists) && Array.isArray(parsed.reminders)) data = parsed;
+        } catch (e) {}
+    }
+
+    var remaining = [];
+    for (var i = 0; i < data.reminders.length; i++) {
+        var r = data.reminders[i];
+        // Drop the reminder itself and any sub-task that points to it
+        if (r.id === reminderId || r.parentId === reminderId) continue;
+        remaining.push(r);
+    }
+    data.reminders = remaining;
+
+    filelib.writeFile(dataPath, JSON.stringify(data));
+    sendJSONResp({ ok: true });
+}

+ 32 - 0
src/web/Reminders/backend/init.agi

@@ -0,0 +1,32 @@
+/*
+    Reminders - Initialize
+    Ensures the Reminders data store exists, seeds a default list on first run,
+    and returns the full { lists, reminders } payload.
+*/
+requirelib("filelib");
+
+var dir      = "user:/Document/Reminders";
+var dataPath = "user:/Document/Reminders/data.json";
+
+filelib.mkdir(dir);
+
+var data = null;
+if (filelib.fileExists(dataPath)) {
+    try {
+        var parsed = JSON.parse(filelib.readFile(dataPath));
+        if (parsed && typeof parsed === "object") data = parsed;
+    } catch (e) {}
+}
+
+if (!data || !Array.isArray(data.lists) || !Array.isArray(data.reminders)) {
+    // First run — seed a default list (mirrors macOS' built-in "Reminders" list)
+    data = {
+        lists: [
+            { id: "ls_default", name: "Reminders", color: "blue", icon: "list", order: 0 }
+        ],
+        reminders: []
+    };
+    filelib.writeFile(dataPath, JSON.stringify(data));
+}
+
+sendJSONResp(data);

+ 24 - 0
src/web/Reminders/backend/saveData.agi

@@ -0,0 +1,24 @@
+/*
+    Reminders - Overwrite the whole { lists, reminders } store.
+    Used for structural changes such as re-ordering or clearing completed items.
+    POST: dataJson (JSON string of the full data object)
+*/
+requirelib("filelib");
+
+var dir      = "user:/Document/Reminders";
+var dataPath = "user:/Document/Reminders/data.json";
+
+var data;
+try {
+    data = JSON.parse(dataJson);
+} catch (e) {
+    sendJSONResp({ error: "Invalid data" });
+}
+
+if (data && Array.isArray(data.lists) && Array.isArray(data.reminders)) {
+    filelib.mkdir(dir);
+    filelib.writeFile(dataPath, JSON.stringify(data));
+    sendJSONResp({ ok: true });
+} else {
+    sendJSONResp({ error: "Malformed data" });
+}

+ 38 - 0
src/web/Reminders/backend/saveList.agi

@@ -0,0 +1,38 @@
+/*
+    Reminders - Save (create or update) a single list.
+    POST: listData (JSON string of a list object)
+*/
+requirelib("filelib");
+
+var dataPath = "user:/Document/Reminders/data.json";
+
+var ls;
+try {
+    ls = JSON.parse(listData);
+} catch (e) {
+    sendJSONResp({ error: "Invalid list data" });
+}
+
+if (ls) {
+    if (!ls.id || ls.id === "") {
+        var ts = new Date().getTime();
+        ls.id = "ls_" + ts.toString(36) + Math.random().toString(36).slice(2, 7);
+    }
+
+    var data = { lists: [], reminders: [] };
+    if (filelib.fileExists(dataPath)) {
+        try {
+            var parsed = JSON.parse(filelib.readFile(dataPath));
+            if (parsed && Array.isArray(parsed.lists) && Array.isArray(parsed.reminders)) data = parsed;
+        } catch (e) {}
+    }
+
+    var found = false;
+    for (var i = 0; i < data.lists.length; i++) {
+        if (data.lists[i].id === ls.id) { data.lists[i] = ls; found = true; break; }
+    }
+    if (!found) data.lists.push(ls);
+
+    filelib.writeFile(dataPath, JSON.stringify(data));
+    sendJSONResp({ ok: true, id: ls.id });
+}

+ 40 - 0
src/web/Reminders/backend/saveReminder.agi

@@ -0,0 +1,40 @@
+/*
+    Reminders - Save (create or update) a single reminder.
+    POST: reminderData (JSON string of a reminder object)
+*/
+requirelib("filelib");
+
+var dataPath = "user:/Document/Reminders/data.json";
+
+var rm;
+try {
+    rm = JSON.parse(reminderData);
+} catch (e) {
+    sendJSONResp({ error: "Invalid reminder data" });
+}
+
+if (rm) {
+    // Ensure the reminder has an id
+    if (!rm.id || rm.id === "") {
+        var ts = new Date().getTime();
+        rm.id = "rm_" + ts.toString(36) + Math.random().toString(36).slice(2, 7);
+    }
+
+    var data = { lists: [], reminders: [] };
+    if (filelib.fileExists(dataPath)) {
+        try {
+            var parsed = JSON.parse(filelib.readFile(dataPath));
+            if (parsed && Array.isArray(parsed.lists) && Array.isArray(parsed.reminders)) data = parsed;
+        } catch (e) {}
+    }
+
+    // Upsert by id
+    var found = false;
+    for (var i = 0; i < data.reminders.length; i++) {
+        if (data.reminders[i].id === rm.id) { data.reminders[i] = rm; found = true; break; }
+    }
+    if (!found) data.reminders.push(rm);
+
+    filelib.writeFile(dataPath, JSON.stringify(data));
+    sendJSONResp({ ok: true, id: rm.id });
+}

+ 19 - 0
src/web/Reminders/img/icon.svg

@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
+  <defs>
+    <linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
+      <stop offset="0" stop-color="#ffffff"/>
+      <stop offset="1" stop-color="#f1f1f4"/>
+    </linearGradient>
+  </defs>
+  <rect x="6" y="6" width="116" height="116" rx="28" fill="url(#bg)" stroke="#e3e3e8" stroke-width="1.5"/>
+  <!-- row 1: checked -->
+  <circle cx="40" cy="44" r="9" fill="#ff3b30"/>
+  <path d="M35.6 44.2l2.9 3 6-6.3" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
+  <rect x="56" y="40.5" width="44" height="7" rx="3.5" fill="#d7d7dd"/>
+  <!-- row 2 -->
+  <circle cx="40" cy="68" r="9" fill="#ff9500"/>
+  <rect x="56" y="64.5" width="44" height="7" rx="3.5" fill="#e2e2e7"/>
+  <!-- row 3 -->
+  <circle cx="40" cy="92" r="9" fill="#34c759"/>
+  <rect x="56" y="88.5" width="34" height="7" rx="3.5" fill="#e2e2e7"/>
+</svg>

+ 1001 - 0
src/web/Reminders/index.html

@@ -0,0 +1,1001 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
+<title>Reminders</title>
+<script src="../script/jquery.min.js"></script>
+<script src="../script/ao_module.js"></script>
+<style>
+*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+:root{
+  --bg:#fff;--bg2:#f7f7f9;--bg3:#ececf0;--border:#e2e2e7;--border2:#d1d1d6;
+  --text:#1c1c1e;--text2:#6c6c70;--text3:#a3a3a8;
+  --accent:#007aff;--hover:rgba(0,0,0,.04);--hover2:rgba(0,0,0,.08);
+  --shadow:0 10px 40px rgba(0,0,0,.16);--danger:#ff3b30;
+  --c-red:#ff3b30;--c-orange:#ff9500;--c-yellow:#ffcc00;--c-green:#34c759;
+  --c-teal:#30b0c7;--c-blue:#007aff;--c-indigo:#5856d6;--c-purple:#af52de;
+  --c-pink:#ff2d55;--c-brown:#a2845e;--c-gray:#8e8e93;
+}
+body.dark{
+  --bg:#1c1c1e;--bg2:#2c2c2e;--bg3:#3a3a3c;--border:#38383a;--border2:#48484a;
+  --text:#fff;--text2:#98989f;--text3:#636366;
+  --accent:#0a84ff;--hover:rgba(255,255,255,.05);--hover2:rgba(255,255,255,.1);
+  --shadow:0 10px 40px rgba(0,0,0,.6);--danger:#ff453a;
+  --c-red:#ff453a;--c-orange:#ff9f0a;--c-yellow:#ffd60a;--c-green:#30d158;
+  --c-teal:#40c8e0;--c-blue:#0a84ff;--c-indigo:#5e5ce6;--c-purple:#bf5af2;
+  --c-pink:#ff375f;--c-brown:#ac8e68;--c-gray:#98989f;
+}
+html,body{height:100%}
+body{font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif;background:var(--bg);color:var(--text);overflow:hidden;-webkit-font-smoothing:antialiased}
+#app{display:grid;grid-template-columns:264px 1fr;height:100vh;height:100dvh}
+
+/* ── SIDEBAR ─────────────────────────────────────────────── */
+#sidebar{background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
+.sb-scroll{flex:1;overflow-y:auto;overflow-x:hidden;padding:12px 14px 8px}
+.search-wrap{position:relative;margin-bottom:14px}
+.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text3);pointer-events:none}
+#searchInp{width:100%;border:none;border-radius:9px;background:var(--bg3);padding:7px 10px 7px 30px;font-size:13px;color:var(--text);outline:none;font-family:inherit}
+#searchInp::placeholder{color:var(--text3)}
+
+.smart-grid{display:grid;grid-template-columns:1fr 1fr;gap:9px;margin-bottom:9px}
+.smart-card{background:var(--bg);border-radius:11px;padding:10px 11px 9px;cursor:pointer;border:1.5px solid transparent;transition:transform .08s,box-shadow .12s;text-align:left;min-height:62px;display:flex;flex-direction:column;justify-content:space-between;position:relative}
+.smart-card:hover{box-shadow:0 2px 8px rgba(0,0,0,.08)}
+.smart-card:active{transform:scale(.98)}
+.smart-card.active{border-color:var(--accent)}
+.sc-top{display:flex;align-items:center;justify-content:space-between}
+.sc-ic{width:26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;flex:0 0 auto}
+.sc-ic svg{width:15px;height:15px}
+.sc-count{font-size:20px;font-weight:700;color:var(--text)}
+.sc-label{font-size:13px;font-weight:600;color:var(--text2);margin-top:5px}
+.smart-card.wide{grid-column:1/3;flex-direction:row;align-items:center;min-height:0;gap:10px;padding:9px 11px}
+.smart-card.wide .sc-label{margin-top:0;flex:1}
+.smart-card.wide .sc-count{font-size:15px;color:var(--text2);font-weight:600}
+
+.sb-h{font-size:12px;font-weight:700;color:var(--text2);text-transform:uppercase;letter-spacing:.04em;padding:14px 6px 5px;display:flex;align-items:center;justify-content:space-between}
+.list-row{display:flex;align-items:center;gap:10px;padding:7px 8px;border-radius:9px;cursor:pointer;transition:background .1s;position:relative}
+.list-row:hover{background:var(--hover2)}
+.list-row.active{background:var(--accent)}
+.list-row.active .lr-name,.list-row.active .lr-count{color:#fff}
+.lr-ic{width:27px;height:27px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;flex:0 0 auto}
+.lr-ic svg{width:15px;height:15px}
+.lr-name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lr-count{font-size:14px;color:var(--text2)}
+.lr-edit{opacity:0;background:none;border:none;color:inherit;cursor:pointer;padding:2px;border-radius:5px;display:flex;flex:0 0 auto}
+.list-row:hover .lr-edit{opacity:.6}
+.lr-edit:hover{opacity:1!important;background:rgba(127,127,127,.2)}
+.list-row.active .lr-count{margin-right:2px}
+
+.sb-foot{border-top:1px solid var(--border);padding:8px 12px;display:flex;align-items:center;gap:6px}
+.foot-btn{background:none;border:none;cursor:pointer;color:var(--accent);font-size:13.5px;font-weight:500;display:flex;align-items:center;gap:6px;padding:6px 6px;border-radius:7px;font-family:inherit}
+.foot-btn:hover{background:var(--hover2)}
+.foot-spacer{flex:1}
+.icon-btn{background:none;border:none;cursor:pointer;color:var(--text2);padding:6px;border-radius:7px;display:flex;align-items:center;justify-content:center}
+.icon-btn:hover{background:var(--hover2);color:var(--text)}
+
+/* ── CONTENT ─────────────────────────────────────────────── */
+#content{display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
+#mainHead{padding:22px 26px 6px;flex:0 0 auto}
+.mh-top{display:flex;align-items:flex-start;gap:12px}
+#mainTitle{font-size:30px;font-weight:800;line-height:1.1;flex:1;word-break:break-word}
+#mainCount{font-size:30px;font-weight:800;color:var(--text3)}
+.mh-add{background:none;border:none;cursor:pointer;color:var(--accent);width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;flex:0 0 auto;transition:background .12s}
+.mh-add:hover{background:var(--hover2)}
+.mh-add svg{width:24px;height:24px}
+.mh-sub{display:flex;align-items:center;justify-content:space-between;margin-top:7px;min-height:18px;border-bottom:1px solid var(--border);padding-bottom:9px}
+.mh-sub-l{font-size:13px;color:var(--text2)}
+.mh-sub-l b{color:var(--text2);font-weight:600}
+.mh-clear{color:var(--accent);cursor:pointer;margin-left:7px}
+.mh-clear:hover{text-decoration:underline}
+.mh-show{font-size:13px;color:var(--accent);cursor:pointer;background:none;border:none;font-family:inherit}
+.mh-show:hover{text-decoration:underline}
+
+#rmScroll{flex:1;overflow-y:auto;padding:4px 14px 60px 18px}
+.grp-h{font-size:14px;font-weight:700;color:var(--text);padding:14px 10px 4px;display:flex;align-items:center;gap:7px}
+.grp-h.overdue{color:var(--danger)}
+.grp-h .gd{width:8px;height:8px;border-radius:50%;background:var(--text3)}
+
+/* reminder row */
+.rm-row{display:flex;align-items:flex-start;gap:11px;padding:8px 8px 8px 8px;border-bottom:1px solid var(--border);position:relative;--c:var(--c-blue)}
+.rm-row.sub{margin-left:30px}
+.rm-row:last-child{border-bottom:none}
+.rm-check{width:21px;height:21px;border-radius:50%;border:1.7px solid var(--border2);background:transparent;cursor:pointer;flex:0 0 auto;margin-top:1px;position:relative;transition:border-color .15s,background .15s;padding:0}
+.rm-row:hover .rm-check{border-color:var(--c)}
+.rm-check svg{position:absolute;inset:0;margin:auto;width:13px;height:13px;color:#fff;opacity:0;transform:scale(.5);transition:opacity .15s,transform .15s}
+.rm-row.done .rm-check{background:var(--c);border-color:var(--c)}
+.rm-row.done .rm-check svg{opacity:1;transform:scale(1)}
+.rm-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}
+.rm-titleline{display:flex;align-items:center;gap:6px}
+.rm-prio{color:var(--c);font-weight:800;font-size:14px;flex:0 0 auto;letter-spacing:-1px}
+.rm-title{flex:1;border:none;background:transparent;font-size:14px;color:var(--text);outline:none;font-family:inherit;padding:1px 0;min-width:0}
+.rm-title::placeholder{color:var(--text3)}
+.rm-row.done .rm-title{color:var(--text3)}
+.rm-flag{color:var(--c-orange);flex:0 0 auto;display:none}
+.rm-row.flagged .rm-flag{display:flex}
+.rm-flag svg{width:13px;height:13px}
+.rm-notes{font-size:12.5px;color:var(--text2);white-space:pre-wrap;word-break:break-word;display:none}
+.rm-notes.show{display:block}
+.rm-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
+.rm-date{font-size:12px;color:var(--text2);display:none}
+.rm-date.show{display:inline}
+.rm-date.overdue{color:var(--danger)}
+.rm-url{font-size:12px;color:var(--accent);text-decoration:none;display:none;max-width:240px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.rm-url.show{display:inline-block}
+.rm-url:hover{text-decoration:underline}
+.rm-info{opacity:0;background:none;border:none;cursor:pointer;color:var(--accent);flex:0 0 auto;padding:2px;border-radius:50%;display:flex;align-items:center;transition:opacity .12s;margin-top:1px}
+.rm-row:hover .rm-info{opacity:.85}
+.rm-info:hover{opacity:1!important;background:var(--hover2)}
+.rm-info svg{width:19px;height:19px}
+
+.empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:60%;color:var(--text3);gap:12px;text-align:center}
+.empty svg{width:54px;height:54px;opacity:.55}
+.empty .e-t{font-size:19px;font-weight:600;color:var(--text2)}
+.empty .e-s{font-size:13px}
+
+/* ── DETAIL POPOVER ──────────────────────────────────────── */
+.pop-back{position:fixed;inset:0;z-index:900;display:none}
+.pop-back.open{display:block}
+#detail{position:fixed;z-index:901;width:330px;max-width:calc(100vw - 24px);background:var(--bg);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow);display:none;overflow:hidden}
+#detail.open{display:block}
+.dt-hd{display:flex;align-items:center;gap:8px;padding:14px 15px 8px}
+#dtTitle{flex:1;font-size:16px;font-weight:600;border:none;background:transparent;color:var(--text);outline:none;font-family:inherit}
+.dt-flag{background:none;border:1px solid var(--border2);border-radius:7px;width:30px;height:28px;cursor:pointer;color:var(--text3);display:flex;align-items:center;justify-content:center;flex:0 0 auto}
+.dt-flag.on{color:#fff;background:var(--c-orange);border-color:var(--c-orange)}
+.dt-flag svg{width:15px;height:15px}
+.dt-body{padding:0 15px 6px}
+#dtNotes{width:100%;border:none;background:transparent;color:var(--text);font-size:13px;outline:none;resize:none;font-family:inherit;padding:2px 0 10px;min-height:34px;border-bottom:1px solid var(--border)}
+#dtNotes::placeholder{color:var(--text3)}
+.dt-card{background:var(--bg2);border-radius:10px;margin-top:11px;overflow:hidden}
+.dt-line{display:flex;align-items:center;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);min-height:40px}
+.dt-line:last-child{border-bottom:none}
+.dt-line .dl-lbl{font-size:13.5px;flex:1;color:var(--text)}
+.dt-sw{position:relative;width:38px;height:23px;flex:0 0 auto}
+.dt-sw input{display:none}
+.dt-sw-tk{display:block;width:38px;height:23px;border-radius:12px;background:var(--border2);cursor:pointer;transition:background .2s;position:relative}
+.dt-sw-tk::after{content:'';position:absolute;top:2px;left:2px;width:19px;height:19px;border-radius:50%;background:#fff;transition:transform .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
+.dt-sw input:checked+.dt-sw-tk{background:var(--c-green)}
+.dt-sw input:checked+.dt-sw-tk::after{transform:translateX(15px)}
+.dt-sub{padding:7px 12px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:9px}
+.dt-sub:last-child{border-bottom:none}
+.dt-sub .ds-ic{width:26px;height:26px;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#fff;flex:0 0 auto}
+.dt-sub .ds-ic svg{width:15px;height:15px}
+.dt-inp{border:none;background:var(--bg);border:1px solid var(--border);border-radius:7px;padding:5px 8px;font-size:13px;color:var(--text);font-family:inherit;outline:none}
+.dt-inp:focus{border-color:var(--accent)}
+input[type=date].dt-inp,input[type=time].dt-inp{cursor:pointer}
+.dt-flex{flex:1}
+select.dt-inp{cursor:pointer}
+.dt-url{width:100%;border:none;background:transparent;color:var(--accent);font-size:13px;outline:none;font-family:inherit}
+.dt-foot{display:flex;gap:8px;padding:11px 15px 14px}
+.dt-del{flex:1;background:none;border:1px solid var(--danger);color:var(--danger);border-radius:9px;padding:8px;font-size:13.5px;font-weight:500;cursor:pointer;font-family:inherit}
+.dt-del:hover{background:rgba(255,59,48,.1)}
+.dt-done{flex:1;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:8px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:inherit}
+.dt-done:hover{filter:brightness(1.08)}
+
+/* ── LIST MODAL ──────────────────────────────────────────── */
+.modal-back{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:center;justify-content:center;z-index:1000;backdrop-filter:blur(3px)}
+.modal-back.open{display:flex}
+.modal{background:var(--bg);border-radius:16px;box-shadow:var(--shadow);width:330px;max-width:calc(100vw - 28px);padding:20px;text-align:center}
+.modal h3{font-size:16px;font-weight:700;margin-bottom:16px}
+.lm-preview{width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;margin:0 auto 14px}
+.lm-preview svg{width:34px;height:34px}
+.lm-name{width:100%;border:1px solid var(--border2);border-radius:9px;padding:9px 12px;font-size:15px;text-align:center;color:var(--text);background:var(--bg2);outline:none;font-family:inherit;margin-bottom:16px}
+.lm-name:focus{border-color:var(--accent)}
+.lm-sec-t{font-size:11px;font-weight:700;color:var(--text3);text-transform:uppercase;letter-spacing:.05em;text-align:left;margin-bottom:8px}
+.swatches{display:flex;flex-wrap:wrap;gap:11px;justify-content:center;margin-bottom:16px}
+.sw{width:28px;height:28px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:transform .1s}
+.sw:hover{transform:scale(1.15)}
+.sw.sel{border-color:var(--text)}
+.iconpick{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-bottom:18px}
+.ip{width:34px;height:34px;border-radius:50%;background:var(--bg3);cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--text2);border:2px solid transparent;transition:transform .1s}
+.ip:hover{transform:scale(1.1)}
+.ip.sel{background:var(--accent);color:#fff}
+.ip svg{width:17px;height:17px}
+.modal-foot{display:flex;gap:9px}
+.m-cancel{flex:1;background:var(--bg3);border:none;border-radius:10px;padding:9px;font-size:14px;cursor:pointer;color:var(--text);font-family:inherit;font-weight:500}
+.m-cancel:hover{filter:brightness(.96)}
+.m-ok{flex:1;background:var(--accent);border:none;border-radius:10px;padding:9px;font-size:14px;font-weight:600;cursor:pointer;color:#fff;font-family:inherit}
+.m-ok:hover{filter:brightness(1.08)}
+.m-del{width:100%;background:none;border:none;color:var(--danger);font-size:13.5px;cursor:pointer;margin-top:12px;padding:6px;font-family:inherit}
+.m-del:hover{text-decoration:underline}
+
+/* scrollbars + snackbar */
+::-webkit-scrollbar{width:8px;height:8px}
+::-webkit-scrollbar-thumb{background:var(--border2);border-radius:8px;border:2px solid var(--bg2)}
+::-webkit-scrollbar-track{background:transparent}
+#snackbar{position:fixed;bottom:22px;left:50%;transform:translateX(-50%) translateY(12px);background:var(--text);color:var(--bg);padding:9px 18px;border-radius:10px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s,transform .2s;z-index:2000;white-space:nowrap}
+#snackbar.show{opacity:1;transform:translateX(-50%) translateY(0)}
+
+/* mobile */
+#scrim{display:none}
+.menu-btn{display:none}
+@media(max-width:760px){
+  #app{grid-template-columns:1fr}
+  #sidebar{position:fixed;top:0;left:0;height:100dvh;width:80vw;max-width:300px;z-index:50;transform:translateX(-100%);transition:transform .22s ease;box-shadow:var(--shadow)}
+  #sidebar.open{transform:translateX(0)}
+  #scrim{display:block;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:49;opacity:0;pointer-events:none;transition:opacity .2s}
+  #scrim.open{opacity:1;pointer-events:auto}
+  .menu-btn{display:flex}
+  #mainHead{padding:16px 18px 6px}
+  #mainTitle,#mainCount{font-size:25px}
+  #detail{width:330px}
+}
+</style>
+</head>
+<body>
+<div id="app">
+  <!-- ───────── SIDEBAR ───────── -->
+  <aside id="sidebar">
+    <div class="sb-scroll">
+      <div class="search-wrap">
+        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
+        <input id="searchInp" placeholder="Search" autocomplete="off" oninput="onSearch(this.value)">
+      </div>
+      <div class="smart-grid" id="smartGrid"></div>
+      <div class="sb-h"><span>My Lists</span></div>
+      <div id="listList"></div>
+    </div>
+    <div class="sb-foot">
+      <button class="foot-btn" onclick="openListModal(null)">
+        <svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
+        Add List
+      </button>
+      <div class="foot-spacer"></div>
+      <button class="icon-btn" id="darkBtn" title="Toggle appearance" onclick="toggleDark()"></button>
+    </div>
+  </aside>
+  <div id="scrim" onclick="closeSidebar()"></div>
+
+  <!-- ───────── CONTENT ───────── -->
+  <main id="content">
+    <div id="mainHead">
+      <div class="mh-top">
+        <button class="icon-btn menu-btn" onclick="openSidebar()" style="margin:2px 0 0 -4px">
+          <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
+        </button>
+        <div id="mainTitle">Reminders</div>
+        <div id="mainCount">0</div>
+        <button class="mh-add" id="addBtn" title="New Reminder" onclick="quickAdd()">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
+        </button>
+      </div>
+      <div class="mh-sub">
+        <div class="mh-sub-l" id="mhSubL"></div>
+        <button class="mh-show" id="mhShow" onclick="toggleShowCompleted()"></button>
+      </div>
+    </div>
+    <div id="rmScroll"></div>
+  </main>
+</div>
+
+<!-- detail popover -->
+<div class="pop-back" id="popBack" onclick="closeDetail()"></div>
+<div id="detail">
+  <div class="dt-hd">
+    <input id="dtTitle" placeholder="Title" autocomplete="off">
+    <button class="dt-flag" id="dtFlag" title="Flag" onclick="dtToggleFlag()">
+      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>
+    </button>
+  </div>
+  <div class="dt-body">
+    <textarea id="dtNotes" placeholder="Notes" rows="1"></textarea>
+    <div class="dt-card">
+      <div class="dt-line">
+        <span class="dl-lbl">On a Day</span>
+        <label class="dt-sw"><input type="checkbox" id="dtOnDay" onchange="dtToggleDay()"><span class="dt-sw-tk"></span></label>
+      </div>
+      <div class="dt-sub" id="dtDateSub" style="display:none">
+        <span class="ds-ic" style="background:var(--c-red)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg></span>
+        <input type="date" class="dt-inp dt-flex" id="dtDate" onchange="dtChanged()">
+      </div>
+      <div class="dt-line">
+        <span class="dl-lbl">At a Time</span>
+        <label class="dt-sw"><input type="checkbox" id="dtOnTime" onchange="dtToggleTime()"><span class="dt-sw-tk"></span></label>
+      </div>
+      <div class="dt-sub" id="dtTimeSub" style="display:none">
+        <span class="ds-ic" style="background:var(--c-blue)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></span>
+        <input type="time" class="dt-inp dt-flex" id="dtTime" onchange="dtChanged()">
+      </div>
+    </div>
+    <div class="dt-card">
+      <div class="dt-line">
+        <span class="dl-lbl">Priority</span>
+        <select class="dt-inp" id="dtPrio" onchange="dtChanged()">
+          <option value="0">None</option>
+          <option value="1">Low</option>
+          <option value="2">Medium</option>
+          <option value="3">High</option>
+        </select>
+      </div>
+      <div class="dt-line">
+        <span class="dl-lbl">Repeat</span>
+        <select class="dt-inp" id="dtRepeat" onchange="dtChanged()">
+          <option value="">Never</option>
+          <option value="FREQ=DAILY">Daily</option>
+          <option value="FREQ=WEEKLY">Weekly</option>
+          <option value="FREQ=MONTHLY">Monthly</option>
+          <option value="FREQ=YEARLY">Yearly</option>
+        </select>
+      </div>
+      <div class="dt-line">
+        <span class="dl-lbl">List</span>
+        <select class="dt-inp" id="dtList" onchange="dtChanged()"></select>
+      </div>
+      <div class="dt-sub">
+        <span class="ds-ic" style="background:var(--c-gray)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg></span>
+        <input class="dt-url" id="dtUrl" placeholder="Add URL" autocomplete="off" oninput="dtChanged()">
+      </div>
+    </div>
+  </div>
+  <div class="dt-foot">
+    <button class="dt-del" onclick="dtDelete()">Delete</button>
+    <button class="dt-done" onclick="closeDetail()">Done</button>
+  </div>
+</div>
+
+<!-- list create / edit modal -->
+<div class="modal-back" id="listModal">
+  <div class="modal">
+    <h3 id="lmHd">New List</h3>
+    <div class="lm-preview" id="lmPrev"></div>
+    <input class="lm-name" id="lmName" placeholder="List Name" autocomplete="off" oninput="lmRenderPreview()">
+    <div class="lm-sec-t">Color</div>
+    <div class="swatches" id="lmSwatches"></div>
+    <div class="lm-sec-t">Icon</div>
+    <div class="iconpick" id="lmIcons"></div>
+    <div class="modal-foot">
+      <button class="m-cancel" onclick="closeListModal()">Cancel</button>
+      <button class="m-ok" onclick="lmSave()">Done</button>
+    </div>
+    <button class="m-del" id="lmDel" onclick="lmDelete()" style="display:none">Delete List</button>
+  </div>
+</div>
+
+<div id="snackbar"></div>
+
+<script>
+// ══════════════════════════════════════════════════════════════════════
+//  Reminders — a macOS Reminders-style web app for ArozOS
+// ══════════════════════════════════════════════════════════════════════
+
+// ── Palettes ──────────────────────────────────────────────────────────
+var COLOR_KEYS = ['red','orange','yellow','green','teal','blue','indigo','purple','pink','brown','gray'];
+function colorVar(c){ return 'var(--c-'+(c||'blue')+')'; }
+
+// White-glyph icons (24x24, stroke based). Key -> inner SVG markup.
+var ICONS = {
+  list:'<line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4.5" cy="6" r="1.3" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.3" fill="currentColor" stroke="none"/>',
+  star:'<polygon points="12 2.5 15 9 22 9.7 16.8 14.3 18.4 21.5 12 17.7 5.6 21.5 7.2 14.3 2 9.7 9 9"/>',
+  flag:'<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
+  heart:'<path d="M20.8 5.6a5 5 0 0 0-7-.2L12 7l-1.8-1.6a5 5 0 0 0-7 7.2L12 21l8.8-8.4a5 5 0 0 0 0-7z"/>',
+  cart:'<circle cx="9" cy="20" r="1.4"/><circle cx="18" cy="20" r="1.4"/><path d="M2 3h2.5l2.3 12.4a1.5 1.5 0 0 0 1.5 1.2h8.7a1.5 1.5 0 0 0 1.5-1.2L21.5 7H6"/>',
+  house:'<path d="M3 11l9-7 9 7"/><path d="M5 10v10h14V10"/><rect x="10" y="14" width="4" height="6"/>',
+  work:'<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="3" y1="13" x2="21" y2="13"/>',
+  book:'<path d="M4 4h11a3 3 0 0 1 3 3v13H7a3 3 0 0 1-3-3z"/><path d="M18 7h2v13H7"/>',
+  leaf:'<path d="M4 20s0-9 7-13c4-2.3 9-2 9-2s.3 5-2 9c-4 7-13 7-13 7z"/><line x1="6" y1="18" x2="13" y2="11"/>',
+  gift:'<rect x="3" y="9" width="18" height="12" rx="1"/><line x1="12" y1="9" x2="12" y2="21"/><path d="M3 13h18"/><path d="M12 9S10 3 7 4.5 9 9 12 9zM12 9s2-6 5-4.5S15 9 12 9z"/>',
+  travel:'<path d="M21 16v-2l-8-5V3.5a1.5 1.5 0 0 0-3 0V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5z"/>',
+  bell:'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
+  tag:'<path d="M20 12l-8.5 8.5a2 2 0 0 1-2.8 0L3 14.8V4h10.8l6.2 6.2a2 2 0 0 1 0 1.8z"/><circle cx="8" cy="9" r="1.4" fill="currentColor" stroke="none"/>',
+  bolt:'<polygon points="13 2 4 14 11 14 10 22 20 9 13 9"/>',
+  music:'<circle cx="6" cy="18" r="2.5"/><circle cx="17" cy="16" r="2.5"/><path d="M8.5 18V6l11-2v10"/>',
+  paw:'<circle cx="7" cy="9" r="2"/><circle cx="12" cy="6.5" r="2"/><circle cx="17" cy="9" r="2"/><path d="M12 11c-3 0-5 2.5-5 5a3 3 0 0 0 3 3c1 0 1.3-.5 2-.5s1 .5 2 .5a3 3 0 0 0 3-3c0-2.5-2-5-5-5z"/>'
+};
+var ICON_KEYS = Object.keys(ICONS);
+function iconSvg(key, sz){ sz=sz||24; return '<svg viewBox="0 0 24 24" width="'+sz+'" height="'+sz+'" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+(ICONS[key]||ICONS.list)+'</svg>'; }
+
+// ── State ─────────────────────────────────────────────────────────────
+var S = {
+  lists:[], reminders:[],
+  sel:{ type:'smart', id:'today' },     // {type:'smart'|'list', id}
+  showCompleted:false,
+  search:'',
+  dark:false,
+  loaded:false
+};
+var SMART = [
+  { id:'today',     label:'Today',     color:'blue',   icon:'<rect x="3" y="4" width="18" height="18" rx="3"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="8" y1="2.5" x2="8" y2="6"/><line x1="16" y1="2.5" x2="16" y2="6"/>' },
+  { id:'scheduled', label:'Scheduled', color:'red',    icon:'<rect x="3" y="4" width="18" height="18" rx="3"/><line x1="3" y1="9" x2="21" y2="9"/><circle cx="8" cy="14" r="1.3" fill="currentColor" stroke="none"/><circle cx="12" cy="14" r="1.3" fill="currentColor" stroke="none"/><circle cx="16" cy="14" r="1.3" fill="currentColor" stroke="none"/>' },
+  { id:'all',       label:'All',       color:'gray',   icon:'<path d="M3 7l3-4h12l3 4"/><path d="M3 7v11a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V7"/><path d="M3 12h5l2 3h4l2-3h5"/>' },
+  { id:'flagged',   label:'Flagged',   color:'orange', icon:'<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>' }
+];
+var CHECK_SVG='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="5 12.5 10 17.5 19 6.5"/></svg>';
+var INFO_SVG='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9.2"/><line x1="12" y1="11" x2="12" y2="16.5"/><circle cx="12" cy="7.7" r="1" fill="currentColor" stroke="none"/></svg>';
+
+// ── Date helpers ──────────────────────────────────────────────────────
+var DAYS_SHORT=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
+var DAYS_LONG=['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
+var MONTHS_SHORT=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
+function pad2(n){ return (n<10?'0':'')+n; }
+function ymd(d){ return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); }
+function todayStr(){ return ymd(new Date()); }
+function parseYmd(s){ var p=String(s).split('-'); return new Date(+p[0],(+p[1])-1,+p[2]); }
+function dayDiff(a,b){ return Math.round((parseYmd(a)-parseYmd(b))/86400000); }
+function fmtTime(t){ if(!t) return ''; var p=t.split(':'),h=+p[0],m=+p[1]; var ap=h<12?'AM':'PM'; return (h%12||12)+':'+pad2(m)+' '+ap; }
+function isOverdue(rm){
+  if(!rm.dueDate) return false;
+  var d=dayDiff(rm.dueDate, todayStr());
+  if(d<0) return true;
+  if(d>0) return false;
+  if(rm.dueTime){ var now=new Date(),p=rm.dueTime.split(':'); return (now.getHours()*60+now.getMinutes())>(+p[0]*60+ +p[1]); }
+  return false;
+}
+function fmtDateChip(rm){
+  if(!rm.dueDate) return '';
+  var due=parseYmd(rm.dueDate), d=dayDiff(rm.dueDate, todayStr()), label;
+  if(d===0) label='Today';
+  else if(d===1) label='Tomorrow';
+  else if(d===-1) label='Yesterday';
+  else { label=DAYS_SHORT[due.getDay()]+', '+MONTHS_SHORT[due.getMonth()]+' '+due.getDate(); if(due.getFullYear()!==new Date().getFullYear()) label+=', '+due.getFullYear(); }
+  if(rm.dueTime) label+=' '+fmtTime(rm.dueTime);
+  return label;
+}
+
+// ── Recurrence (RRULE) helpers ────────────────────────────────────────
+// We support the four simple frequencies; an RRULE synced from iOS that uses
+// one of them is recognised, anything more exotic is shown as a generic chip.
+function rruleFreq(rrule){ var m=String(rrule||'').match(/FREQ=([A-Z]+)/); return m?m[1]:''; }
+function repeatSelectVal(rrule){
+  var f=rruleFreq(rrule);
+  return ['DAILY','WEEKLY','MONTHLY','YEARLY'].indexOf(f)>=0 ? ('FREQ='+f) : '';
+}
+function repeatLabel(rrule){
+  return { DAILY:'Daily', WEEKLY:'Weekly', MONTHLY:'Monthly', YEARLY:'Yearly' }[rruleFreq(rrule)] || (rrule?'Repeats':'');
+}
+// Advance a YYYY-MM-DD string to the next occurrence for the given RRULE.
+function nextOccurrence(dateStr, rrule){
+  var d=parseYmd(dateStr), f=rruleFreq(rrule);
+  if(f==='DAILY')        d.setDate(d.getDate()+1);
+  else if(f==='WEEKLY')  d.setDate(d.getDate()+7);
+  else if(f==='MONTHLY') d.setMonth(d.getMonth()+1);
+  else if(f==='YEARLY')  d.setFullYear(d.getFullYear()+1);
+  else return null;
+  return ymd(d);
+}
+
+// ── Misc helpers ──────────────────────────────────────────────────────
+function escHtml(s){ return String(s==null?'':s).replace(/[&<>"']/g,function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
+function genId(p){ return p+'_'+Date.now().toString(36)+Math.random().toString(36).slice(2,7); }
+function byId(id){ return document.getElementById(id); }
+function listById(id){ for(var i=0;i<S.lists.length;i++) if(S.lists[i].id===id) return S.lists[i]; return null; }
+function rmById(id){ for(var i=0;i<S.reminders.length;i++) if(S.reminders[i].id===id) return S.reminders[i]; return null; }
+function listColor(id){ var l=listById(id); return l?l.color:'blue'; }
+var _sbT; function snack(m){ var el=byId('snackbar'); el.textContent=m; el.classList.add('show'); clearTimeout(_sbT); _sbT=setTimeout(function(){ el.classList.remove('show'); },2200); }
+// transient debounce timers (kept off the reminder objects so they never get persisted)
+var _saveTimers={}, _rerenderT, _dtT;
+
+// ── Persistence (backend AGI) ─────────────────────────────────────────
+function loadData(cb){
+  ao_module_agirun('Reminders/backend/init.agi',{},function(d){
+    try{ var o=(typeof d==='string'?JSON.parse(d):d); S.lists=o.lists||[]; S.reminders=o.reminders||[]; }
+    catch(e){ S.lists=[]; S.reminders=[]; }
+    S.loaded=true; cb&&cb();
+  },function(){ S.lists=[]; S.reminders=[]; S.loaded=true; cb&&cb(); });
+}
+function persistReminder(rm){ ao_module_agirun('Reminders/backend/saveReminder.agi',{reminderData:JSON.stringify(rm)},function(d){
+  try{ var o=(typeof d==='string'?JSON.parse(d):d); if(o&&o.id&&!rm.id) rm.id=o.id; }catch(e){}
+}); }
+function persistDeleteReminder(id){ ao_module_agirun('Reminders/backend/deleteReminder.agi',{reminderId:id},function(){}); }
+function persistList(ls){ ao_module_agirun('Reminders/backend/saveList.agi',{listData:JSON.stringify(ls)},function(){}); }
+function persistDeleteList(id){ ao_module_agirun('Reminders/backend/deleteList.agi',{listId:id},function(){}); }
+function persistAll(){ ao_module_agirun('Reminders/backend/saveData.agi',{dataJson:JSON.stringify({lists:S.lists,reminders:S.reminders})},function(){}); }
+
+// ── Counts ────────────────────────────────────────────────────────────
+function smartCount(id){
+  var t=todayStr();
+  if(id==='today')     return S.reminders.filter(function(r){ return !r.completed && r.dueDate===t; }).length;
+  if(id==='scheduled') return S.reminders.filter(function(r){ return !r.completed && r.dueDate; }).length;
+  if(id==='all')       return S.reminders.filter(function(r){ return !r.completed; }).length;
+  if(id==='flagged')   return S.reminders.filter(function(r){ return !r.completed && r.flagged; }).length;
+  if(id==='completed') return S.reminders.filter(function(r){ return r.completed; }).length;
+  return 0;
+}
+function listCount(id){ return S.reminders.filter(function(r){ return r.listId===id && !r.completed; }).length; }
+
+// ══ RENDER ════════════════════════════════════════════════════════════
+function render(){ renderSidebar(); renderMain(); }
+
+function renderSidebar(){
+  // smart cards
+  var g='';
+  SMART.forEach(function(s){
+    var active=(S.sel.type==='smart'&&S.sel.id===s.id)?' active':'';
+    g+='<button class="smart-card'+active+'" onclick="selectSmart(\''+s.id+'\')">'
+      +'<div class="sc-top"><span class="sc-ic" style="background:'+colorVar(s.color)+'">'
+      +'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+s.icon+'</svg></span>'
+      +'<span class="sc-count">'+smartCount(s.id)+'</span></div>'
+      +'<div class="sc-label">'+s.label+'</div></button>';
+  });
+  // completed (wide)
+  var cActive=(S.sel.type==='smart'&&S.sel.id==='completed')?' active':'';
+  g+='<button class="smart-card wide'+cActive+'" onclick="selectSmart(\'completed\')">'
+    +'<span class="sc-ic" style="width:24px;height:24px;background:var(--c-gray)">'+CHECK_SVG+'</span>'
+    +'<span class="sc-label">Completed</span><span class="sc-count">'+smartCount('completed')+'</span></button>';
+  byId('smartGrid').innerHTML=g;
+
+  // my lists
+  var ls=S.lists.slice().sort(function(a,b){ return (a.order||0)-(b.order||0); });
+  var h='';
+  ls.forEach(function(l){
+    var active=(S.sel.type==='list'&&S.sel.id===l.id)?' active':'';
+    h+='<div class="list-row'+active+'" onclick="selectList(\''+l.id+'\')">'
+      +'<span class="lr-ic" style="background:'+colorVar(l.color)+'">'+iconSvg(l.icon,15)+'</span>'
+      +'<span class="lr-name">'+escHtml(l.name)+'</span>'
+      +'<span class="lr-count">'+listCount(l.id)+'</span>'
+      +'<button class="lr-edit" title="Edit list" onclick="event.stopPropagation();openListModal(\''+l.id+'\')">'
+      +'<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg></button>'
+      +'</div>';
+  });
+  if(!ls.length) h='<div style="color:var(--text3);font-size:13px;padding:6px 8px">No lists yet</div>';
+  byId('listList').innerHTML=h;
+}
+
+// Resolve the current selection -> {title, color, reminders[]}
+function currentTitle(){
+  if(S.sel.type==='smart'){ if(S.sel.id==='completed') return 'Completed'; var s=SMART.filter(function(x){return x.id===S.sel.id;})[0]; return s?s.label:''; }
+  var l=listById(S.sel.id); return l?l.name:'';
+}
+function currentColor(){
+  if(S.sel.type==='smart'){ if(S.sel.id==='completed') return 'gray'; var s=SMART.filter(function(x){return x.id===S.sel.id;})[0]; return s?s.color:'blue'; }
+  return listColor(S.sel.id);
+}
+
+function renderMain(){
+  var color=currentColor();
+  byId('mainTitle').textContent= S.search ? 'Search' : currentTitle();
+  byId('mainTitle').style.color= S.search ? 'var(--text)' : colorVar(color);
+
+  // header count + sub line
+  var headCount;
+  if(S.search){ headCount=''; }
+  else if(S.sel.type==='smart'){ headCount=smartCount(S.sel.id); }
+  else { headCount=listCount(S.sel.id); }
+  byId('mainCount').textContent=headCount;
+
+  var isCompletedView=(S.sel.type==='smart'&&S.sel.id==='completed');
+  var addBtn=byId('addBtn'); addBtn.style.display=(isCompletedView||S.search)?'none':'flex';
+
+  // completed sub-line (only for normal lists)
+  var subL=byId('mhSubL'), showBtn=byId('mhShow');
+  if(S.sel.type==='list' && !S.search){
+    var doneN=S.reminders.filter(function(r){ return r.listId===S.sel.id && r.completed; }).length;
+    subL.innerHTML='<b>'+doneN+'</b> Completed'+(doneN?' <span class="mh-clear" onclick="clearCompleted()">&nbsp;Clear</span>':'');
+    showBtn.textContent=S.showCompleted?'Hide':'Show';
+    showBtn.style.display=doneN?'block':'none';
+    subL.style.display='block';
+  } else { subL.style.display='none'; showBtn.style.display='none'; }
+  byId('mainHead').querySelector('.mh-sub').style.display=(S.sel.type==='list'&&!S.search)?'flex':'none';
+
+  // build the item set + grouping
+  var groups=buildGroups();
+  var html='';
+  var total=0;
+  groups.forEach(function(grp){
+    total+=grp.items.length;
+    if(grp.label){ html+='<div class="grp-h'+(grp.overdue?' overdue':'')+'">'+(grp.dot?'<span class="gd"></span>':'')+escHtml(grp.label)+'</div>'; }
+    grp.items.forEach(function(rm){ html+=rowHtml(rm, grp.showList); });
+  });
+  if(total===0){ html=emptyState(); }
+  byId('rmScroll').innerHTML=html;
+}
+
+// Decide which reminders show + how they are grouped for the current view
+function buildGroups(){
+  var t=todayStr();
+  // search overrides everything
+  if(S.search){
+    var q=S.search.toLowerCase();
+    var hits=S.reminders.filter(function(r){ return (r.title||'').toLowerCase().indexOf(q)>=0 || (r.notes||'').toLowerCase().indexOf(q)>=0; });
+    return groupByList(hits);
+  }
+  if(S.sel.type==='list'){
+    var items=S.reminders.filter(function(r){ return r.listId===S.sel.id; });
+    if(!S.showCompleted) items=items.filter(function(r){ return !r.completed; });
+    return [{ label:'', items:orderTree(items) }];
+  }
+  // smart lists
+  var id=S.sel.id;
+  if(id==='completed'){
+    var done=S.reminders.filter(function(r){ return r.completed; });
+    done.sort(function(a,b){ return (b.completedAt||0)-(a.completedAt||0); });
+    return [{ label:'', items:done, showList:true }];
+  }
+  if(id==='all'){
+    var all=S.reminders.filter(function(r){ return !r.completed; });
+    return groupByList(all);
+  }
+  if(id==='flagged'){
+    var fl=S.reminders.filter(function(r){ return !r.completed && r.flagged; });
+    return [{ label:'', items:orderTree(fl), showList:true }];
+  }
+  if(id==='today'){
+    var td=S.reminders.filter(function(r){ return !r.completed && r.dueDate===t; });
+    return groupByDaypart(td);
+  }
+  if(id==='scheduled'){
+    var sc=S.reminders.filter(function(r){ return !r.completed && r.dueDate; });
+    return groupByDate(sc);
+  }
+  return [{ label:'', items:[] }];
+}
+
+// keep parent/child ordering (top-level sorted, each followed by its children)
+function orderTree(items){
+  var map={}; items.forEach(function(r){ map[r.id]=r; });
+  var tops=items.filter(function(r){ return !r.parentId || !map[r.parentId]; });
+  tops.sort(cmpOrder);
+  var out=[];
+  tops.forEach(function(p){
+    out.push(p);
+    var kids=items.filter(function(r){ return r.parentId===p.id; }).sort(cmpOrder);
+    kids.forEach(function(k){ out.push(k); });
+  });
+  return out;
+}
+function cmpOrder(a,b){
+  if(a.completed!==b.completed) return a.completed?1:-1;
+  return (a.order||0)-(b.order||0) || (a.createdAt||0)-(b.createdAt||0);
+}
+function groupByList(items){
+  var groups=[];
+  var ls=S.lists.slice().sort(function(a,b){ return (a.order||0)-(b.order||0); });
+  ls.forEach(function(l){
+    var its=orderTree(items.filter(function(r){ return r.listId===l.id; }));
+    if(its.length) groups.push({ label:l.name, dot:false, items:its, color:l.color });
+  });
+  // orphans (list deleted)
+  var orphan=items.filter(function(r){ return !listById(r.listId); });
+  if(orphan.length) groups.push({ label:'Other', items:orderTree(orphan) });
+  return groups;
+}
+function groupByDaypart(items){
+  var buckets={ none:[], morning:[], afternoon:[], tonight:[] };
+  items.forEach(function(r){
+    if(!r.dueTime){ buckets.none.push(r); return; }
+    var h=+r.dueTime.split(':')[0];
+    if(h<12) buckets.morning.push(r); else if(h<17) buckets.afternoon.push(r); else buckets.tonight.push(r);
+  });
+  var sortT=function(a,b){ return (a.dueTime||'').localeCompare(b.dueTime||''); };
+  var out=[];
+  if(buckets.none.length)      out.push({ label:'All-Day',   dot:true, items:orderTree(buckets.none), showList:true });
+  if(buckets.morning.length)   out.push({ label:'Morning',   dot:true, items:buckets.morning.sort(sortT), showList:true });
+  if(buckets.afternoon.length) out.push({ label:'Afternoon', dot:true, items:buckets.afternoon.sort(sortT), showList:true });
+  if(buckets.tonight.length)   out.push({ label:'Tonight',   dot:true, items:buckets.tonight.sort(sortT), showList:true });
+  return out;
+}
+function groupByDate(items){
+  var t=todayStr(), map={};
+  items.forEach(function(r){ (map[r.dueDate]=map[r.dueDate]||[]).push(r); });
+  var keys=Object.keys(map).sort();
+  var out=[], overdue=[];
+  keys.forEach(function(k){
+    var d=dayDiff(k,t);
+    var bucket=map[k].sort(function(a,b){ return (a.dueTime||'').localeCompare(b.dueTime||''); });
+    if(d<0){ overdue=overdue.concat(bucket); return; }
+    var due=parseYmd(k), label= d===0?'Today':d===1?'Tomorrow':DAYS_LONG[due.getDay()]+', '+MONTHS_SHORT[due.getMonth()]+' '+due.getDate();
+    out.push({ label:label, dot:true, items:bucket, showList:true });
+  });
+  if(overdue.length) out.unshift({ label:'Overdue', dot:true, overdue:true, items:overdue, showList:true });
+  return out;
+}
+
+function rowHtml(rm, showList){
+  var c=colorVar(listColor(rm.listId));
+  var cls='rm-row'+(rm.completed?' done':'')+(rm.flagged?' flagged':'')+(rm.parentId?' sub':'');
+  var prio= rm.priority?('<span class="rm-prio">'+(rm.priority>=3?'!!!':rm.priority===2?'!!':'!')+'</span>'):'';
+  var notes= rm.notes?('<div class="rm-notes show">'+escHtml(rm.notes)+'</div>'):'';
+  var chip='';
+  if(rm.dueDate){ chip='<span class="rm-date show'+(isOverdue(rm)&&!rm.completed?' overdue':'')+'">'+escHtml(fmtDateChip(rm))+'</span>'; }
+  var listTag='';
+  if(showList){ var l=listById(rm.listId); if(l){ listTag='<span class="rm-date show" style="color:'+colorVar(l.color)+'">'+escHtml(l.name)+'</span>'; } }
+  var url= rm.url?('<a class="rm-url show" href="'+escHtml(rm.url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+escHtml(rm.url)+'</a>'):'';
+  var rep= rm.rrule?('<span class="rm-date show" title="Repeats">↻ '+escHtml(repeatLabel(rm.rrule))+'</span>'):'';
+  var meta=(chip||rep||url||listTag)?('<div class="rm-meta">'+chip+rep+listTag+url+'</div>'):'';
+  return '<div class="'+cls+'" data-id="'+rm.id+'" style="--c:'+c+'">'
+    +'<button class="rm-check" data-act="check"><span style="pointer-events:none">'+CHECK_SVG+'</span></button>'
+    +'<div class="rm-body">'
+      +'<div class="rm-titleline">'+prio
+        +'<input class="rm-title" data-act="title" value="'+escHtml(rm.title)+'" placeholder="New Reminder">'
+        +'<span class="rm-flag">'+iconFlag()+'</span>'
+      +'</div>'+notes+meta
+    +'</div>'
+    +'<button class="rm-info" data-act="info" title="Details">'+INFO_SVG+'</button>'
+  +'</div>';
+}
+function iconFlag(){ return '<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M5 14s1-.8 3.5-.8S13 15 16 15s3-.8 3-.8V4s-1 .8-3 .8S11.5 3 8.5 3 5 3.8 5 3.8z"/></svg>'; }
+
+function emptyState(){
+  var t,s;
+  if(S.search){ t='No Results'; s='No reminders match “'+escHtml(S.search)+'”'; }
+  else { t='No Reminders'; s=(S.sel.type==='smart'&&S.sel.id==='completed')?'Completed reminders will appear here':'Tap + to add a reminder'; }
+  return '<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5 5-5.5"/></svg>'
+    +'<div class="e-t">'+t+'</div><div class="e-s">'+s+'</div></div>';
+}
+
+// ══ SELECTION / NAV ═══════════════════════════════════════════════════
+function selectSmart(id){ S.sel={type:'smart',id:id}; S.showCompleted=(id==='completed'); persistUI(); render(); closeSidebar(); }
+function selectList(id){ S.sel={type:'list',id:id}; S.showCompleted=false; persistUI(); render(); closeSidebar(); }
+function onSearch(v){ S.search=v.trim(); renderMain(); }
+function toggleShowCompleted(){ S.showCompleted=!S.showCompleted; renderMain(); }
+
+// ══ REMINDER ACTIONS ══════════════════════════════════════════════════
+// Defaults for a new reminder based on the current view
+function newReminderDefaults(){
+  var listId, dueDate='', flagged=false;
+  if(S.sel.type==='list'){ listId=S.sel.id; }
+  else {
+    var first=S.lists[0]; listId=first?first.id:null;
+    if(S.sel.id==='today')     dueDate=todayStr();
+    if(S.sel.id==='scheduled') dueDate=todayStr();
+    if(S.sel.id==='flagged')   flagged=true;
+  }
+  return { listId:listId, dueDate:dueDate, flagged:flagged };
+}
+function makeReminder(over){
+  var d=newReminderDefaults();
+  var rm={ id:genId('rm'), listId:d.listId, parentId:null, title:'', notes:'', completed:false,
+           completedAt:null, flagged:d.flagged, priority:0, dueDate:d.dueDate, dueTime:'', url:'',
+           rrule:'', createdAt:Date.now(), order:Date.now() };
+  if(over) for(var k in over) rm[k]=over[k];
+  return rm;
+}
+function quickAdd(){
+  if(!S.lists.length){ snack('Create a list first'); openListModal(null); return; }
+  var rm=makeReminder(null);
+  S.reminders.push(rm); persistReminder(rm); render();
+  focusTitle(rm.id);
+}
+// add a sibling after a given reminder (Enter key)
+function addAfter(afterId){
+  var ref=rmById(afterId); if(!ref) return;
+  var rm=makeReminder({ listId:ref.listId, parentId:ref.parentId, dueDate:ref.parentId?'':ref.dueDate, order:(ref.order||0)+1 });
+  S.reminders.push(rm); persistReminder(rm); renderMain();
+  focusTitle(rm.id);
+}
+function focusTitle(id){
+  setTimeout(function(){
+    var row=document.querySelector('.rm-row[data-id="'+id+'"]'); if(!row) return;
+    var inp=row.querySelector('.rm-title'); if(inp){ inp.focus(); var v=inp.value; inp.value=''; inp.value=v; }
+  },30);
+}
+function toggleComplete(id){
+  var rm=rmById(id); if(!rm) return;
+  // Recurring reminders roll forward to their next occurrence instead of being
+  // marked done, matching iOS Reminders behaviour.
+  if(!rm.completed && rm.rrule && rm.dueDate && !rm.parentId){
+    var nd=nextOccurrence(rm.dueDate, rm.rrule);
+    if(nd){
+      rm.dueDate=nd; persistReminder(rm); snack('Moved to '+fmtDateChip(rm));
+      renderSidebar(); clearTimeout(_rerenderT); _rerenderT=setTimeout(renderMain, 480);
+      return;
+    }
+  }
+  rm.completed=!rm.completed; rm.completedAt=rm.completed?Date.now():null;
+  // also complete/uncomplete subtasks
+  S.reminders.forEach(function(r){ if(r.parentId===id){ r.completed=rm.completed; r.completedAt=rm.completed?Date.now():null; persistReminder(r); } });
+  persistReminder(rm);
+  var row=document.querySelector('.rm-row[data-id="'+id+'"]'); if(row) row.classList.toggle('done',rm.completed);
+  renderSidebar();
+  clearTimeout(_rerenderT); _rerenderT=setTimeout(renderMain, 480);
+}
+function setTitle(id,val){
+  var rm=rmById(id); if(!rm) return;
+  rm.title=val; clearTimeout(_saveTimers[id]); _saveTimers[id]=setTimeout(function(){ persistReminder(rm); },400);
+}
+function indentReminder(id){
+  var rm=rmById(id); if(rm.parentId) return;          // already a subtask (one level only)
+  // find previous sibling in the same list among top-level items
+  var sibs=orderTree(S.reminders.filter(function(r){ return r.listId===rm.listId && !r.parentId; }));
+  var idx=sibs.findIndex(function(r){ return r.id===id; });
+  if(idx<=0) return;
+  var parent=sibs[idx-1];
+  if(parent.parentId) return;
+  rm.parentId=parent.id; rm.dueDate=''; rm.dueTime='';
+  persistReminder(rm); renderMain(); focusTitle(id);
+}
+function outdentReminder(id){
+  var rm=rmById(id); if(!rm.parentId) return;
+  rm.parentId=null; persistReminder(rm); renderMain(); focusTitle(id);
+}
+function deleteReminder(id, silent){
+  var rm=rmById(id); if(!rm) return;
+  S.reminders=S.reminders.filter(function(r){ return r.id!==id && r.parentId!==id; });
+  persistDeleteReminder(id);
+  if(!silent) render();
+}
+
+// Event delegation on the reminder list
+(function bindRmList(){
+  var root=byId('rmScroll');
+  root.addEventListener('click',function(e){
+    var row=e.target.closest('.rm-row'); if(!row) return;
+    var id=row.dataset.id, actEl=e.target.closest('[data-act]'), act=actEl?actEl.dataset.act:null;
+    if(act==='check'){ toggleComplete(id); }
+    else if(act==='info'){ openDetail(id, row.querySelector('.rm-info')); }
+    else if(!e.target.classList.contains('rm-title') && e.target.tagName!=='A'){ var inp=row.querySelector('.rm-title'); if(inp) inp.focus(); }
+  });
+  root.addEventListener('input',function(e){ if(e.target.classList.contains('rm-title')){ setTitle(e.target.closest('.rm-row').dataset.id, e.target.value); } });
+  root.addEventListener('keydown',function(e){
+    if(!e.target.classList.contains('rm-title')) return;
+    var id=e.target.closest('.rm-row').dataset.id, inp=e.target;
+    if(e.key==='Enter'){ e.preventDefault(); inp.blur(); var rm=rmById(id); if(rm) persistReminder(rm); addAfter(id); }
+    else if(e.key==='Tab'){ e.preventDefault(); if(e.shiftKey) outdentReminder(id); else indentReminder(id); }
+    else if(e.key==='Backspace' && inp.value==='' && inp.selectionStart===0){
+      e.preventDefault();
+      // focus previous row's title, then delete this empty one
+      var rows=Array.prototype.slice.call(root.querySelectorAll('.rm-row'));
+      var i=rows.findIndex(function(r){ return r.dataset.id===id; });
+      deleteReminder(id, true); renderMain();
+      if(i>0){ var prev=rows[i-1]; var pid=prev.dataset.id; var p=document.querySelector('.rm-row[data-id="'+pid+'"] .rm-title'); if(p){ p.focus(); p.selectionStart=p.selectionEnd=p.value.length; } }
+    }
+  });
+  root.addEventListener('focusout',function(e){ if(e.target.classList.contains('rm-title')){ var rm=rmById(e.target.closest('.rm-row').dataset.id); if(rm) persistReminder(rm); } });
+})();
+
+function clearCompleted(){
+  var done=S.reminders.filter(function(r){ return r.listId===S.sel.id && r.completed; });
+  if(!done.length) return;
+  S.reminders=S.reminders.filter(function(r){ return !(r.listId===S.sel.id && r.completed); });
+  persistAll(); render(); snack('Cleared '+done.length+' completed');
+}
+
+// ══ DETAIL POPOVER ════════════════════════════════════════════════════
+var DT={ id:null };
+function openDetail(id, anchor){
+  var rm=rmById(id); if(!rm) return;
+  DT.id=id;
+  byId('dtTitle').value=rm.title||'';
+  byId('dtNotes').value=rm.notes||'';
+  autoGrow(byId('dtNotes'));
+  byId('dtFlag').classList.toggle('on', !!rm.flagged);
+  byId('dtOnDay').checked=!!rm.dueDate;
+  byId('dtDateSub').style.display=rm.dueDate?'flex':'none';
+  byId('dtDate').value=rm.dueDate||todayStr();
+  byId('dtOnTime').checked=!!rm.dueTime;
+  byId('dtTimeSub').style.display=rm.dueTime?'flex':'none';
+  byId('dtTime').value=rm.dueTime||'09:00';
+  byId('dtPrio').value=String(rm.priority||0);
+  byId('dtRepeat').value=repeatSelectVal(rm.rrule);
+  byId('dtUrl').value=rm.url||'';
+  // list dropdown
+  var sel=byId('dtList'); sel.innerHTML='';
+  S.lists.forEach(function(l){ var o=document.createElement('option'); o.value=l.id; o.textContent=l.name; if(l.id===rm.listId) o.selected=true; sel.appendChild(o); });
+
+  byId('popBack').classList.add('open');
+  var d=byId('detail'); d.classList.add('open');
+  positionDetail(anchor);
+  setTimeout(function(){ byId('dtTitle').focus(); },20);
+}
+function positionDetail(anchor){
+  var d=byId('detail');
+  if(window.innerWidth<=760){ d.style.left='50%'; d.style.top='50%'; d.style.transform='translate(-50%,-50%)'; return; }
+  d.style.transform='none';
+  var r=anchor?anchor.getBoundingClientRect():{right:window.innerWidth/2+160,top:120,bottom:140};
+  var w=330, h=d.offsetHeight||440;
+  var left=r.right-w; if(left<10) left=10; if(left+w>window.innerWidth-10) left=window.innerWidth-w-10;
+  var top=r.bottom+6; if(top+h>window.innerHeight-10) top=Math.max(10, r.top-h-6);
+  if(top<10) top=10;
+  d.style.left=left+'px'; d.style.top=top+'px';
+}
+function dtCur(){ return rmById(DT.id); }
+function dtChanged(){
+  var rm=dtCur(); if(!rm) return;
+  rm.title=byId('dtTitle').value;
+  rm.notes=byId('dtNotes').value;
+  rm.priority=parseInt(byId('dtPrio').value,10)||0;
+  rm.rrule=byId('dtRepeat').value;
+  rm.url=byId('dtUrl').value.trim();
+  rm.listId=byId('dtList').value;
+  rm.dueDate=byId('dtOnDay').checked?byId('dtDate').value:'';
+  rm.dueTime=(byId('dtOnDay').checked&&byId('dtOnTime').checked)?byId('dtTime').value:'';
+  clearTimeout(_dtT); _dtT=setTimeout(function(){ persistReminder(rm); },300);
+}
+function dtToggleFlag(){ var rm=dtCur(); if(!rm) return; rm.flagged=!rm.flagged; byId('dtFlag').classList.toggle('on',rm.flagged); persistReminder(rm); }
+function dtToggleDay(){
+  var on=byId('dtOnDay').checked; byId('dtDateSub').style.display=on?'flex':'none';
+  if(!on){ byId('dtOnTime').checked=false; byId('dtTimeSub').style.display='none'; }
+  dtChanged();
+}
+function dtToggleTime(){
+  if(byId('dtOnTime').checked && !byId('dtOnDay').checked){ byId('dtOnDay').checked=true; byId('dtDateSub').style.display='flex'; }
+  byId('dtTimeSub').style.display=byId('dtOnTime').checked?'flex':'none';
+  dtChanged();
+}
+function dtDelete(){
+  var id=DT.id; DT.id=null; clearTimeout(_dtT);
+  byId('popBack').classList.remove('open'); byId('detail').classList.remove('open');
+  deleteReminder(id);   // removes it + subtasks, then re-renders
+}
+function closeDetail(){
+  var rm=dtCur(); if(rm) persistReminder(rm);
+  byId('popBack').classList.remove('open'); byId('detail').classList.remove('open'); DT.id=null;
+  render();
+}
+byId('dtTitle').addEventListener('input',dtChanged);
+byId('dtNotes').addEventListener('input',function(){ autoGrow(this); dtChanged(); });
+function autoGrow(el){ el.style.height='auto'; el.style.height=Math.max(34,el.scrollHeight)+'px'; }
+
+// ══ LIST MODAL ════════════════════════════════════════════════════════
+var LM={ id:null, color:'blue', icon:'list' };
+function openListModal(id){
+  var editing=!!id;
+  byId('lmHd').textContent=editing?'List Info':'New List';
+  byId('lmDel').style.display=editing?'block':'none';
+  if(editing){ var l=listById(id); LM={ id:id, color:l.color, icon:l.icon }; byId('lmName').value=l.name; }
+  else { LM={ id:null, color:'blue', icon:'list' }; byId('lmName').value=''; }
+  // swatches
+  byId('lmSwatches').innerHTML=COLOR_KEYS.map(function(c){ return '<div class="sw'+(c===LM.color?' sel':'')+'" data-c="'+c+'" style="background:'+colorVar(c)+'" onclick="lmPickColor(\''+c+'\')"></div>'; }).join('');
+  // icons
+  byId('lmIcons').innerHTML=ICON_KEYS.map(function(k){ return '<div class="ip'+(k===LM.icon?' sel':'')+'" data-k="'+k+'" onclick="lmPickIcon(\''+k+'\')">'+iconSvg(k,17)+'</div>'; }).join('');
+  lmRenderPreview();
+  byId('listModal').classList.add('open');
+  setTimeout(function(){ if(!editing) byId('lmName').focus(); },30);
+}
+function lmPickColor(c){ LM.color=c; document.querySelectorAll('#lmSwatches .sw').forEach(function(e){ e.classList.toggle('sel',e.dataset.c===c); }); lmRenderPreview(); }
+function lmPickIcon(k){ LM.icon=k; document.querySelectorAll('#lmIcons .ip').forEach(function(e){ e.classList.toggle('sel',e.dataset.k===k); }); lmRenderPreview(); }
+function lmRenderPreview(){ var p=byId('lmPrev'); p.style.background=colorVar(LM.color); p.innerHTML=iconSvg(LM.icon,34); }
+function lmSave(){
+  var name=byId('lmName').value.trim()||'New List';
+  if(LM.id){ var l=listById(LM.id); l.name=name; l.color=LM.color; l.icon=LM.icon; persistList(l); }
+  else { var nl={ id:genId('ls'), name:name, color:LM.color, icon:LM.icon, order:Date.now() }; S.lists.push(nl); persistList(nl); S.sel={type:'list',id:nl.id}; }
+  closeListModal(); render();
+}
+function lmDelete(){
+  if(!LM.id) return;
+  var l=listById(LM.id); var n=listCount(LM.id);
+  if(!confirm('Delete “'+l.name+'”'+(n?' and its '+n+' reminder(s)':'')+'?')) return;
+  S.lists=S.lists.filter(function(x){ return x.id!==LM.id; });
+  S.reminders=S.reminders.filter(function(r){ return r.listId!==LM.id; });
+  persistDeleteList(LM.id);
+  if(S.sel.type==='list'&&S.sel.id===LM.id) S.sel={type:'smart',id:'today'};
+  closeListModal(); render();
+}
+function closeListModal(){ byId('listModal').classList.remove('open'); }
+
+// ══ DARK MODE / UI PERSISTENCE ════════════════════════════════════════
+var SUN='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4.5"/><line x1="12" y1="2" x2="12" y2="4.5"/><line x1="12" y1="19.5" x2="12" y2="22"/><line x1="4" y1="12" x2="1.8" y2="12"/><line x1="22.2" y1="12" x2="20" y2="12"/><line x1="5.6" y1="5.6" x2="4" y2="4"/><line x1="20" y1="20" x2="18.4" y2="18.4"/><line x1="5.6" y1="18.4" x2="4" y2="20"/><line x1="20" y1="4" x2="18.4" y2="5.6"/></svg>';
+var MOON='<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg>';
+function applyDark(on){ S.dark=on; document.body.classList.toggle('dark',on); byId('darkBtn').innerHTML=on?SUN:MOON; }
+function toggleDark(){ applyDark(!S.dark); persistUI(); }
+function persistUI(){
+  try{ localStorage.setItem('reminders.ui', JSON.stringify({ dark:S.dark, sel:S.sel })); }catch(e){}
+}
+function restoreUI(){
+  try{
+    var o=JSON.parse(localStorage.getItem('reminders.ui')||'{}');
+    if(typeof o.dark==='boolean') S.dark=o.dark;
+    if(o.sel&&o.sel.type) S.sel=o.sel;
+  }catch(e){}
+  if(typeof S.dark!=='boolean' || localStorage.getItem('reminders.ui')===null){
+    S.dark=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches;
+  }
+}
+
+// ══ SIDEBAR (mobile) ══════════════════════════════════════════════════
+function openSidebar(){ byId('sidebar').classList.add('open'); byId('scrim').classList.add('open'); }
+function closeSidebar(){ byId('sidebar').classList.remove('open'); byId('scrim').classList.remove('open'); }
+
+// ══ GLOBAL KEYS ═══════════════════════════════════════════════════════
+document.addEventListener('keydown',function(e){
+  if(e.key==='Escape'){
+    if(byId('detail').classList.contains('open')){ closeDetail(); return; }
+    if(byId('listModal').classList.contains('open')){ closeListModal(); return; }
+    closeSidebar();
+  }
+});
+window.addEventListener('resize',function(){ if(byId('detail').classList.contains('open')) positionDetail(null); });
+
+// ══ BOOT ══════════════════════════════════════════════════════════════
+function boot(){
+  restoreUI();
+  applyDark(S.dark);
+  if(typeof ao_module_setWindowTitle==='function'){ try{ ao_module_setWindowTitle('Reminders'); }catch(e){} }
+  loadData(function(){
+    // make sure the restored selection still exists
+    if(S.sel.type==='list' && !listById(S.sel.id)){ S.sel={type:'smart',id:'today'}; }
+    render();
+  });
+}
+boot();
+</script>
+</body>
+</html>

+ 19 - 0
src/web/Reminders/init.agi

@@ -0,0 +1,19 @@
+/*
+    Reminders Module Registration
+*/
+
+var moduleLaunchInfo = {
+    Name: "Reminders",
+    Desc: "Organise your to-dos with lists, due dates, flags and priorities",
+    Group: "Office",
+    IconPath: "Reminders/img/icon.svg",
+    Version: "1.0",
+    StartDir: "Reminders/index.html",
+    SupportFW: true,
+    LaunchFWDir: "Reminders/index.html",
+    SupportEmb: false,
+    InitFWSize: [980, 680],
+    SupportedExt: []
+}
+
+registerModule(JSON.stringify(moduleLaunchInfo));