Skip to main content

max / goingson

Fix TaskStatus/kanban mismatch, remove invalid Yearly recurrence JS used "Active" for task status but Rust serializes as "Started", causing tasks to silently fall into the wrong bucket. Kanban drag-drop to Started column never called the start API. Removed invalid "Yearly" recurrence option (Rust enum only has Daily/Weekly/Monthly/None). Also includes dep updates and theme command cleanup from prior sessions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-23 01:24 UTC
Commit: 58aa9cc8b60ae6000e3b032dbc10ae7c9b13c0bc
Parent: 11ac0c3
6 files changed, +26 insertions, -224 deletions
M Cargo.lock +9
@@ -1894,6 +1894,7 @@ dependencies = [
1894 1894 "tauri-plugin-updater",
1895 1895 "tauri-plugin-window-state",
1896 1896 "tempfile",
1897 + "theme-common",
1897 1898 "thiserror 1.0.69",
1898 1899 "tokio",
1899 1900 "tokio-native-tls",
@@ -6040,6 +6041,14 @@ dependencies = [
6040 6041 ]
6041 6042
6042 6043 [[package]]
6044 + name = "theme-common"
6045 + version = "0.3.0"
6046 + dependencies = [
6047 + "serde",
6048 + "toml 0.8.2",
6049 + ]
6050 +
6051 + [[package]]
6043 6052 name = "thin-vec"
6044 6053 version = "0.2.14"
6045 6054 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +1
@@ -67,6 +67,7 @@ tauri-plugin-updater = "2"
67 67 rhai = { version = "1.17", features = ["sync", "serde"] }
68 68 notify = "6.0"
69 69 notify-debouncer-mini = "0.4"
70 + theme-common = { path = "../theme-common" }
70 71 toml = "0.8"
71 72
72 73 # Enums
@@ -67,6 +67,7 @@ icalendar = { workspace = true }
67 67 flate2 = { workspace = true }
68 68
69 69 # Theme loading
70 + theme-common = { workspace = true }
70 71 toml = { workspace = true }
71 72 dirs = { workspace = true }
72 73
@@ -8,7 +8,7 @@
8 8
9 9 const COLUMNS = [
10 10 { status: 'Pending', label: 'Pending' },
11 - { status: 'Active', label: 'Started' },
11 + { status: 'Started', label: 'Started' },
12 12 { status: 'Completed', label: 'Completed' },
13 13 ];
14 14
@@ -19,7 +19,7 @@
19 19 if (!board) return;
20 20
21 21 // Group tasks by status
22 - const grouped = { Pending: [], Active: [], Completed: [] };
22 + const grouped = { Pending: [], Started: [], Completed: [] };
23 23 for (const task of tasks) {
24 24 const status = task.status || 'Pending';
25 25 if (grouped[status]) {
@@ -120,7 +120,7 @@
120 120
121 121 async function handleDrop(taskId, task, newStatus) {
122 122 try {
123 - if (newStatus === 'Active') {
123 + if (newStatus === 'Started') {
124 124 await GoingsOn.api.tasks.start(taskId);
125 125 GoingsOn.ui.showToast('Task started!', 'success');
126 126 } else if (newStatus === 'Completed') {
@@ -40,12 +40,11 @@
40 40 { value: 'Daily', label: 'Daily' },
41 41 { value: 'Weekly', label: 'Weekly' },
42 42 { value: 'Monthly', label: 'Monthly' },
43 - { value: 'Yearly', label: 'Yearly' },
44 43 ];
45 44
46 45 const STATUS_OPTIONS = [
47 46 { value: 'Pending', label: 'Pending' },
48 - { value: 'Active', label: 'Active' },
47 + { value: 'Started', label: 'Started' },
49 48 { value: 'Completed', label: 'Completed' },
50 49 ];
51 50
@@ -1,12 +1,12 @@
1 - //! Theme loading commands — reads TOML themes from bundled resources, dev fallback, and user config.
1 + //! Theme loading commands — thin Tauri wrappers over `theme_common`.
2 2
3 - use serde::Serialize;
4 - use std::collections::HashMap;
5 3 use std::path::PathBuf;
6 4 use tauri::{AppHandle, Manager};
7 5 use tracing::instrument;
8 6
9 - use super::{ApiError, ResultApiError};
7 + use super::ApiError;
8 +
9 + pub use theme_common::{ThemeColors, ThemeMeta};
10 10
11 11 /// Returns theme directories in priority order (later overrides earlier by ID).
12 12 /// Each entry is `(path, is_custom)`.
@@ -22,15 +22,8 @@ fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
22 22 }
23 23
24 24 // 2. Dev fallback: CARGO_MANIFEST_DIR → 3 parents up → Git/themes/
25 - let dev_themes = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
26 - .parent() // goingson/
27 - .and_then(|p| p.parent()) // active/
28 - .and_then(|p| p.parent()) // Git/
29 - .map(|p| p.join("themes"));
30 - if let Some(ref dev_path) = dev_themes {
31 - if dev_path.is_dir() {
32 - dirs.push((dev_path.clone(), false));
33 - }
25 + if let Some(dev_path) = theme_common::dev_themes_dir(env!("CARGO_MANIFEST_DIR").as_ref(), 3) {
26 + dirs.push((dev_path, false));
34 27 }
35 28
36 29 // 3. User custom themes (highest priority)
@@ -44,223 +37,22 @@ fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
44 37 dirs
45 38 }
46 39
47 - fn validate_theme_id(id: &str) -> Result<(), ApiError> {
48 - if !id.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') {
49 - return Err(ApiError::bad_request(format!("Invalid theme ID: {}", id)));
50 - }
51 - Ok(())
52 - }
53 -
54 - #[derive(Debug, Serialize)]
55 - #[serde(rename_all = "camelCase")]
56 - pub struct ThemeMeta {
57 - pub id: String,
58 - pub name: String,
59 - pub variant: String,
60 - pub is_custom: bool,
61 - }
62 -
63 - #[derive(Debug, Serialize)]
64 - #[serde(rename_all = "camelCase")]
65 - pub struct ThemeColors {
66 - pub meta: ThemeMeta,
67 - pub colors: HashMap<String, String>,
68 - }
69 -
70 - /// Parse a TOML theme file into its meta fields.
71 - fn parse_meta(id: &str, table: &toml::Table, is_custom: bool) -> ThemeMeta {
72 - let meta = table.get("meta").and_then(|m| m.as_table());
73 - let name = meta
74 - .and_then(|m| m.get("name"))
75 - .and_then(|v| v.as_str())
76 - .unwrap_or(id)
77 - .to_string();
78 - let variant = meta
79 - .and_then(|m| m.get("variant"))
80 - .and_then(|v| v.as_str())
81 - .unwrap_or("dark")
82 - .to_string();
83 -
84 - ThemeMeta {
85 - id: id.to_string(),
86 - name,
87 - variant,
88 - is_custom,
89 - }
90 - }
91 -
92 40 #[tauri::command]
93 41 #[instrument(skip_all)]
94 42 pub fn list_themes(app: AppHandle) -> Result<Vec<ThemeMeta>, ApiError> {
95 43 let dirs = theme_dirs(&app);
96 - // Collect themes, later dirs override earlier by ID
97 - let mut seen: HashMap<String, ThemeMeta> = HashMap::new();
98 -
99 - for (dir, is_custom) in &dirs {
100 - let entries = match std::fs::read_dir(dir) {
101 - Ok(e) => e,
102 - Err(_) => continue,
103 - };
104 -
105 - for entry in entries {
106 - let entry = match entry {
107 - Ok(e) => e,
108 - Err(_) => continue,
109 - };
110 - let path = entry.path();
111 - if path.extension().and_then(|e| e.to_str()) != Some("toml") {
112 - continue;
113 - }
114 -
115 - let id = path
116 - .file_stem()
117 - .and_then(|s| s.to_str())
118 - .unwrap_or_default()
119 - .to_string();
120 -
121 - let content = match std::fs::read_to_string(&path) {
122 - Ok(c) => c,
123 - Err(_) => continue,
124 - };
125 - let table: toml::Table = match content.parse() {
126 - Ok(t) => t,
127 - Err(_) => continue,
128 - };
129 -
130 - seen.insert(id.clone(), parse_meta(&id, &table, *is_custom));
131 - }
132 - }
133 -
134 - let mut themes: Vec<ThemeMeta> = seen.into_values().collect();
135 - themes.sort_by(|a, b| a.name.cmp(&b.name));
136 - Ok(themes)
137 - }
138 -
139 - /// Find a theme file by ID, checking directories in reverse priority order
140 - /// (user custom first, then dev, then bundled).
141 - fn find_theme_path(app: &AppHandle, id: &str) -> Result<(PathBuf, bool), ApiError> {
142 - let dirs = theme_dirs(app);
143 - let filename = format!("{}.toml", id);
144 -
145 - // Check in reverse so highest-priority dir wins
146 - for (dir, is_custom) in dirs.iter().rev() {
147 - let path = dir.join(&filename);
148 - if path.is_file() {
149 - return Ok((path, *is_custom));
150 - }
151 - }
152 -
153 - Err(ApiError::not_found("theme", id))
44 + Ok(theme_common::list_themes_from_dirs(&dirs))
154 45 }
155 46
156 47 #[tauri::command]
157 48 #[instrument(skip_all)]
158 49 pub fn get_theme(app: AppHandle, id: String) -> Result<ThemeColors, ApiError> {
159 - validate_theme_id(&id)?;
160 -
161 - let (path, is_custom) = find_theme_path(&app, &id)?;
162 - let content = std::fs::read_to_string(&path)
163 - .map_api_err(
164 - &format!("Failed to read {}", path.display()),
165 - ApiError::internal,
166 - )?;
167 - let table: toml::Table = content
168 - .parse()
169 - .map_err(|e| ApiError::internal(format!("Failed to parse {}: {}", path.display(), e)))?;
170 -
171 - let meta = parse_meta(&id, &table, is_custom);
172 -
173 - let mut colors = HashMap::new();
174 - for section in &["background", "foreground", "accent", "border"] {
175 - if let Some(sect) = table.get(*section).and_then(|s| s.as_table()) {
176 - for (key, val) in sect {
177 - if let Some(color) = val.as_str() {
178 - colors.insert(format!("{}.{}", section, key), color.to_string());
179 - }
180 - }
181 - }
182 - }
183 -
184 - Ok(ThemeColors { meta, colors })
50 + let dirs = theme_dirs(&app);
51 + theme_common::load_theme(&dirs, &id).map_err(|e| ApiError::internal(e))
185 52 }
186 53
187 54 #[cfg(test)]
188 55 mod tests {
189 - use super::*;
190 -
191 - // -- validate_theme_id tests --
192 -
193 - #[test]
194 - fn validate_theme_id_alphanumeric() {
195 - assert!(validate_theme_id("darkmode").is_ok());
196 - assert!(validate_theme_id("Theme123").is_ok());
197 - }
198 -
199 - #[test]
200 - fn validate_theme_id_hyphens_underscores() {
201 - assert!(validate_theme_id("dark-mode").is_ok());
202 - assert!(validate_theme_id("my_theme_v2").is_ok());
203 - assert!(validate_theme_id("a-b_c-d").is_ok());
204 - }
205 -
206 - #[test]
207 - fn validate_theme_id_rejects_spaces() {
208 - assert!(validate_theme_id("has space").is_err());
209 - }
210 -
211 - #[test]
212 - fn validate_theme_id_rejects_path_traversal() {
213 - assert!(validate_theme_id("../etc/passwd").is_err());
214 - assert!(validate_theme_id("foo/bar").is_err());
215 - }
216 -
217 - #[test]
218 - fn validate_theme_id_rejects_special_chars() {
219 - assert!(validate_theme_id("evil<script>").is_err());
220 - assert!(validate_theme_id("theme;drop").is_err());
221 - assert!(validate_theme_id("theme.toml").is_err());
222 - }
223 -
224 - #[test]
225 - fn validate_theme_id_empty_is_valid() {
226 - assert!(validate_theme_id("").is_ok(), "empty string has no invalid chars");
227 - }
228 -
229 - // -- parse_meta tests --
230 -
231 - #[test]
232 - fn parse_meta_with_name_and_variant() {
233 - let toml_str = r#"
234 - [meta]
235 - name = "Solarized Dark"
236 - variant = "light"
237 - "#;
238 - let table: toml::Table = toml_str.parse().unwrap();
239 - let meta = parse_meta("solarized", &table, false);
240 - assert_eq!(meta.id, "solarized");
241 - assert_eq!(meta.name, "Solarized Dark");
242 - assert_eq!(meta.variant, "light");
243 - assert!(!meta.is_custom);
244 - }
245 -
246 - #[test]
247 - fn parse_meta_defaults_to_id_and_dark() {
248 - let table: toml::Table = "".parse().unwrap();
249 - let meta = parse_meta("fallback", &table, true);
250 - assert_eq!(meta.name, "fallback");
251 - assert_eq!(meta.variant, "dark");
252 - assert!(meta.is_custom);
253 - }
254 -
255 - #[test]
256 - fn parse_meta_missing_variant_defaults_dark() {
257 - let toml_str = r#"
258 - [meta]
259 - name = "Minimal"
260 - "#;
261 - let table: toml::Table = toml_str.parse().unwrap();
262 - let meta = parse_meta("minimal", &table, false);
263 - assert_eq!(meta.name, "Minimal");
264 - assert_eq!(meta.variant, "dark");
265 - }
56 + // Core theme logic tests live in theme-common crate.
57 + // App-specific tests here only if needed for theme_dirs behavior.
266 58 }