super-time-tracker-ui 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (276) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +28 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +36 -0
  5. package/app/api/backup/route.ts +39 -0
  6. package/app/api/entry/delete-bulk/route.ts +53 -0
  7. package/app/api/entry/move/route.ts +46 -0
  8. package/app/api/entry/move-bulk/route.ts +62 -0
  9. package/app/api/entry/route.ts +75 -0
  10. package/app/api/in/route.ts +38 -0
  11. package/app/api/note/route.ts +120 -0
  12. package/app/api/out/route.ts +31 -0
  13. package/app/api/sheet/route.ts +68 -0
  14. package/app/api/state/route.ts +16 -0
  15. package/app/api/tags/route.ts +75 -0
  16. package/app/color-palettes.css +260 -0
  17. package/app/favicon.ico +0 -0
  18. package/app/globals.css +140 -0
  19. package/app/layout.tsx +54 -0
  20. package/app/page.tsx +24 -0
  21. package/app/reporting/page.tsx +11 -0
  22. package/app/settings/data/page.tsx +9 -0
  23. package/app/settings/display/page.tsx +8 -0
  24. package/app/settings/page.tsx +12 -0
  25. package/app/settings/tags/page.tsx +13 -0
  26. package/bin/stt-ui.js +63 -0
  27. package/components/active-entry-panel.tsx +199 -0
  28. package/components/backup-restore-setting.tsx +168 -0
  29. package/components/check-in-form-collapsed-setting.tsx +44 -0
  30. package/components/check-in-form-collapsible.tsx +52 -0
  31. package/components/check-in-form.tsx +89 -0
  32. package/components/checkbox.tsx +75 -0
  33. package/components/checkout-button-group.tsx +73 -0
  34. package/components/chevron-icon.tsx +25 -0
  35. package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
  36. package/components/color-palette-setting.tsx +75 -0
  37. package/components/compact-lists-setting.tsx +42 -0
  38. package/components/confirm-before-checkout-setting.tsx +42 -0
  39. package/components/confirm-destructive-actions-setting.tsx +46 -0
  40. package/components/confirm-dialog-provider.tsx +71 -0
  41. package/components/confirm-dialog.tsx +90 -0
  42. package/components/data-settings-view.tsx +47 -0
  43. package/components/default-reporting-range-setting.tsx +56 -0
  44. package/components/default-reporting-sort-setting.tsx +45 -0
  45. package/components/default-sheet-session-setting.tsx +118 -0
  46. package/components/display-settings-view.tsx +75 -0
  47. package/components/duration-format-setting.tsx +40 -0
  48. package/components/entry-actions-menu.tsx +207 -0
  49. package/components/entry-edit-form.tsx +113 -0
  50. package/components/entry-list-bulk-bar.tsx +128 -0
  51. package/components/entry-list-sort-setting.tsx +41 -0
  52. package/components/entry-list.tsx +336 -0
  53. package/components/entry-notes-list.tsx +211 -0
  54. package/components/entry-tag-filter.tsx +99 -0
  55. package/components/format_datetime_hint.ts +8 -0
  56. package/components/format_time.ts +10 -0
  57. package/components/general-settings-view.tsx +40 -0
  58. package/components/hamburger-icon.tsx +21 -0
  59. package/components/note-edit-form.tsx +77 -0
  60. package/components/note-form.tsx +109 -0
  61. package/components/pencil-icon.tsx +21 -0
  62. package/components/reporting-date-range-picker.tsx +121 -0
  63. package/components/reporting-sort-controls.tsx +53 -0
  64. package/components/reporting-view.tsx +340 -0
  65. package/components/setting-radio-group.tsx +79 -0
  66. package/components/settings-nav.tsx +66 -0
  67. package/components/settings-page-layout.tsx +53 -0
  68. package/components/settings-saved-toast.tsx +57 -0
  69. package/components/sheet-actions-menu.tsx +108 -0
  70. package/components/sheet-sidebar.tsx +196 -0
  71. package/components/tag-autocomplete-input.tsx +183 -0
  72. package/components/tag-filter-mode-setting.tsx +47 -0
  73. package/components/tag-management-view.tsx +290 -0
  74. package/components/theme-mode-setting.tsx +44 -0
  75. package/components/theme-mode-system-listener.tsx +43 -0
  76. package/components/theme_switcher.tsx +38 -0
  77. package/components/time-format-setting.tsx +39 -0
  78. package/components/timer-in-title-setting.tsx +38 -0
  79. package/components/timer-show-seconds-setting.tsx +41 -0
  80. package/components/tracker-active-bar.tsx +76 -0
  81. package/components/tracker-app.tsx +338 -0
  82. package/components/tracker-breadcrumb.tsx +56 -0
  83. package/components/tracker-document-title.tsx +67 -0
  84. package/components/tracker-topbar.tsx +63 -0
  85. package/components/trash-icon.tsx +24 -0
  86. package/components/week-starts-on-setting.tsx +39 -0
  87. package/eslint.config.mjs +18 -0
  88. package/lib/add_note_to_entry.ts +65 -0
  89. package/lib/api_error_response.ts +10 -0
  90. package/lib/apply_accent_color.ts +12 -0
  91. package/lib/apply_color_palette.ts +12 -0
  92. package/lib/apply_compact_lists.ts +9 -0
  93. package/lib/apply_tag_autocomplete_selection.ts +26 -0
  94. package/lib/apply_theme.ts +8 -0
  95. package/lib/build_reporting_stats.ts +55 -0
  96. package/lib/build_resume_description.ts +15 -0
  97. package/lib/check_in_entry.ts +81 -0
  98. package/lib/check_out_entry.ts +75 -0
  99. package/lib/collect_known_tags.ts +22 -0
  100. package/lib/collect_tag_stats.ts +27 -0
  101. package/lib/collect_tags_from_entries.ts +35 -0
  102. package/lib/config.ts +9 -0
  103. package/lib/convert_json_db.ts +49 -0
  104. package/lib/delete_entries.ts +62 -0
  105. package/lib/delete_entry.ts +29 -0
  106. package/lib/delete_note_on_entry.ts +42 -0
  107. package/lib/delete_sheet.ts +30 -0
  108. package/lib/delete_tracker_action.ts +22 -0
  109. package/lib/edit_entry.ts +56 -0
  110. package/lib/edit_note_on_entry.ts +49 -0
  111. package/lib/ensure_dir_exists.ts +22 -0
  112. package/lib/entry_matches_tag_filter.ts +26 -0
  113. package/lib/fetch_tracker_state.ts +15 -0
  114. package/lib/filter_entries_by_tags.ts +20 -0
  115. package/lib/filter_known_tags.ts +20 -0
  116. package/lib/find_all_serialized_active_entries.ts +28 -0
  117. package/lib/find_serialized_active_entry.ts +12 -0
  118. package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
  119. package/lib/find_sheet_with_active_entry.ts +16 -0
  120. package/lib/format_display_tag.ts +6 -0
  121. package/lib/format_duration.ts +45 -0
  122. package/lib/gen_db.ts +43 -0
  123. package/lib/get_active_panel_class_name.ts +20 -0
  124. package/lib/get_average_entry_ms.ts +13 -0
  125. package/lib/get_button_class_name.ts +24 -0
  126. package/lib/get_check_out_confirm_dialog.ts +19 -0
  127. package/lib/get_clipped_entry_duration_ms.ts +18 -0
  128. package/lib/get_compact_lists_snapshot.ts +15 -0
  129. package/lib/get_date_range_ms_from_inputs.ts +31 -0
  130. package/lib/get_delete_entries_confirm_dialog.ts +21 -0
  131. package/lib/get_delete_entry_confirm_dialog.ts +19 -0
  132. package/lib/get_delete_note_confirm_dialog.ts +21 -0
  133. package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
  134. package/lib/get_entry_duration_ms.ts +14 -0
  135. package/lib/get_entry_row_key.ts +8 -0
  136. package/lib/get_initial_preferred_sheet_name.ts +34 -0
  137. package/lib/get_initial_reporting_range_inputs.ts +31 -0
  138. package/lib/get_input_class_name.ts +15 -0
  139. package/lib/get_merge_tags_confirm_dialog.ts +25 -0
  140. package/lib/get_period_range_ms.ts +43 -0
  141. package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
  142. package/lib/get_reporting_period_totals.ts +39 -0
  143. package/lib/get_reporting_stats.ts +25 -0
  144. package/lib/get_restore_db_confirm_dialog.ts +14 -0
  145. package/lib/get_running_entry_key.ts +8 -0
  146. package/lib/get_serialized_entries_total_ms.ts +10 -0
  147. package/lib/get_sheet.ts +14 -0
  148. package/lib/get_sheet_report_stats.ts +22 -0
  149. package/lib/get_sheet_report_stats_for_range.ts +46 -0
  150. package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
  151. package/lib/get_sheets_duration_in_range.ts +27 -0
  152. package/lib/get_tag_autocomplete_context.ts +32 -0
  153. package/lib/get_theme_snapshot.ts +16 -0
  154. package/lib/get_tracker_state.ts +67 -0
  155. package/lib/has_string_value.ts +6 -0
  156. package/lib/is_entry_in_day.ts +15 -0
  157. package/lib/is_idle_sheet_report.ts +8 -0
  158. package/lib/is_json_time_tracker_db.ts +14 -0
  159. package/lib/merge_tags_across_db.ts +79 -0
  160. package/lib/migrate_json_db.ts +56 -0
  161. package/lib/migrate_json_db_to_version_three.ts +51 -0
  162. package/lib/migrate_json_db_to_version_two.ts +50 -0
  163. package/lib/move_entries_to_sheet.ts +152 -0
  164. package/lib/move_entry_to_sheet.ts +82 -0
  165. package/lib/normalize_stored_tag.ts +16 -0
  166. package/lib/notify_settings_saved.ts +47 -0
  167. package/lib/parse_default_sheet_session_mode.ts +21 -0
  168. package/lib/parse_entry_from_input.ts +23 -0
  169. package/lib/parse_natural_language_date.ts +23 -0
  170. package/lib/parse_reporting_source_sheets.ts +22 -0
  171. package/lib/partition_sheet_report_stats.ts +30 -0
  172. package/lib/patch_tracker_action.ts +22 -0
  173. package/lib/persist_ui_preference.ts +18 -0
  174. package/lib/post_tracker_action.ts +22 -0
  175. package/lib/preferences/accent_color_preference.ts +21 -0
  176. package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
  177. package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
  178. package/lib/preferences/color_palette_preference.ts +21 -0
  179. package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
  180. package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
  181. package/lib/preferences/default_reporting_range_preference.ts +21 -0
  182. package/lib/preferences/default_reporting_sort_preference.ts +24 -0
  183. package/lib/preferences/duration_format_preference.ts +19 -0
  184. package/lib/preferences/entry_list_sort_preference.ts +21 -0
  185. package/lib/preferences/tag_filter_mode_preference.ts +18 -0
  186. package/lib/preferences/theme_mode_preference.ts +18 -0
  187. package/lib/preferences/time_format_preference.ts +18 -0
  188. package/lib/preferences/timer_in_title_preference.ts +18 -0
  189. package/lib/preferences/timer_show_seconds_preference.ts +19 -0
  190. package/lib/preferences/week_starts_on_preference.ts +19 -0
  191. package/lib/prompt_check_out_at.ts +17 -0
  192. package/lib/prompt_entry_note.ts +14 -0
  193. package/lib/prune_sheet_tag_filter.ts +27 -0
  194. package/lib/read_db.ts +49 -0
  195. package/lib/read_db_backup_contents.ts +22 -0
  196. package/lib/read_document_compact_lists.ts +12 -0
  197. package/lib/read_document_theme.ts +14 -0
  198. package/lib/read_sheet_tag_filter.ts +26 -0
  199. package/lib/read_stored_active_sheet.ts +14 -0
  200. package/lib/read_stored_compact_lists.ts +24 -0
  201. package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
  202. package/lib/read_stored_default_sheet_session_mode.ts +18 -0
  203. package/lib/read_stored_sheet_tag_filters.ts +28 -0
  204. package/lib/read_stored_theme.ts +18 -0
  205. package/lib/rename_sheet.ts +39 -0
  206. package/lib/rename_tag_across_db.ts +19 -0
  207. package/lib/resolve_active_sheet_name.ts +36 -0
  208. package/lib/resolve_session_preferred_sheet.ts +37 -0
  209. package/lib/resolve_theme.ts +18 -0
  210. package/lib/resolve_theme_mode_to_theme.ts +19 -0
  211. package/lib/restore_db_from_uploaded_json.ts +24 -0
  212. package/lib/serialize_entry.ts +27 -0
  213. package/lib/serialize_reporting_source_sheets.ts +19 -0
  214. package/lib/serialize_sheet_entries.ts +18 -0
  215. package/lib/set_accent_color.ts +12 -0
  216. package/lib/set_active_sheet.ts +18 -0
  217. package/lib/set_color_palette.ts +12 -0
  218. package/lib/set_compact_lists.ts +12 -0
  219. package/lib/set_default_sheet_fixed_name.ts +8 -0
  220. package/lib/set_default_sheet_session_mode.ts +11 -0
  221. package/lib/set_sheet_tag_filter.ts +13 -0
  222. package/lib/set_theme_mode.ts +19 -0
  223. package/lib/sheet_tag_filter_snapshots.ts +48 -0
  224. package/lib/sort_serialized_entries.ts +35 -0
  225. package/lib/sort_sheet_report_stats.ts +43 -0
  226. package/lib/subscribe_compact_lists.ts +25 -0
  227. package/lib/subscribe_sheet_tag_filters.ts +28 -0
  228. package/lib/subscribe_theme.ts +23 -0
  229. package/lib/sync_active_sheet_preference.ts +19 -0
  230. package/lib/tags_are_equal.ts +12 -0
  231. package/lib/theme_init_script.ts +11 -0
  232. package/lib/toggle_sheet_tag_filter.ts +28 -0
  233. package/lib/toggle_theme.ts +20 -0
  234. package/lib/types/confirm_dialog.ts +9 -0
  235. package/lib/types/data.ts +16 -0
  236. package/lib/types/generic_data.ts +25 -0
  237. package/lib/types/index.ts +2 -0
  238. package/lib/types/reporting.ts +59 -0
  239. package/lib/types/tag_management.ts +7 -0
  240. package/lib/types/theme.ts +3 -0
  241. package/lib/types/tracker_state.ts +39 -0
  242. package/lib/types/ui_preferences.ts +104 -0
  243. package/lib/types/ui_settings.ts +17 -0
  244. package/lib/ui_preference_store.ts +80 -0
  245. package/lib/ui_settings_init_script.ts +33 -0
  246. package/lib/use_check_in_form_collapsed.ts +18 -0
  247. package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
  248. package/lib/use_confirm_before_checkout.ts +18 -0
  249. package/lib/use_confirm_destructive_actions.ts +18 -0
  250. package/lib/use_duration_format.ts +17 -0
  251. package/lib/use_entry_list_sort.ts +17 -0
  252. package/lib/use_tag_filter_mode.ts +17 -0
  253. package/lib/use_time_format.ts +17 -0
  254. package/lib/use_timer_in_title.ts +18 -0
  255. package/lib/use_timer_show_seconds.ts +18 -0
  256. package/lib/use_week_starts_on.ts +17 -0
  257. package/lib/validate_entry_times.ts +12 -0
  258. package/lib/week_starts_on_to_index.ts +8 -0
  259. package/lib/write_active_sheet_preference.ts +28 -0
  260. package/lib/write_db.ts +20 -0
  261. package/lib/write_sheet_tag_filter.ts +20 -0
  262. package/lib/write_stored_compact_lists.ts +15 -0
  263. package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
  264. package/lib/write_stored_default_sheet_session_mode.ts +24 -0
  265. package/lib/write_stored_sheet_tag_filters.ts +18 -0
  266. package/lib/write_stored_theme.ts +12 -0
  267. package/next.config.ts +7 -0
  268. package/package.json +96 -0
  269. package/pnpm-workspace.yaml +7 -0
  270. package/postcss.config.mjs +7 -0
  271. package/public/file.svg +1 -0
  272. package/public/globe.svg +1 -0
  273. package/public/next.svg +1 -0
  274. package/public/vercel.svg +1 -0
  275. package/public/window.svg +1 -0
  276. package/tsconfig.json +34 -0
@@ -0,0 +1,79 @@
1
+ import { collect_tag_stats } from '@/lib/collect_tag_stats'
2
+ import { normalize_stored_tag } from '@/lib/normalize_stored_tag'
3
+ import { read_db } from '@/lib/read_db'
4
+ import { tags_are_equal } from '@/lib/tags_are_equal'
5
+ import { type TagStat } from '@/lib/types/tag_management'
6
+ import { write_db } from '@/lib/write_db'
7
+
8
+ export interface MergeTagsAcrossDbResult {
9
+ entries_updated: number
10
+ tags: TagStat[]
11
+ }
12
+
13
+ /**
14
+ * Replaces source tags with a target tag on every entry and deduplicates tag lists.
15
+ */
16
+ export async function merge_tags_across_db(
17
+ source_tags: string[],
18
+ target_tag: string,
19
+ ): Promise<MergeTagsAcrossDbResult> {
20
+ const normalized_target = normalize_stored_tag(target_tag)
21
+ const normalized_sources = [
22
+ ...new Set(
23
+ source_tags
24
+ .map((tag) => normalize_stored_tag(tag))
25
+ .filter((tag) => !tags_are_equal(tag, normalized_target)),
26
+ ),
27
+ ]
28
+
29
+ if (normalized_sources.length === 0) {
30
+ throw new Error('Choose at least one source tag different from the target.')
31
+ }
32
+
33
+ const db = await read_db()
34
+ let entries_updated = 0
35
+
36
+ for (const sheet of db.sheets) {
37
+ for (const entry of sheet.entries) {
38
+ let changed = false
39
+ const next_tags: string[] = []
40
+ const seen = new Set<string>()
41
+
42
+ for (const tag of entry.tags) {
43
+ let next_tag: string
44
+
45
+ try {
46
+ next_tag = normalize_stored_tag(tag)
47
+ } catch {
48
+ next_tag = tag
49
+ }
50
+
51
+ if (
52
+ normalized_sources.some((source_tag) => tags_are_equal(source_tag, next_tag))
53
+ ) {
54
+ next_tag = normalized_target
55
+ changed = true
56
+ }
57
+
58
+ if (!seen.has(next_tag)) {
59
+ seen.add(next_tag)
60
+ next_tags.push(next_tag)
61
+ }
62
+ }
63
+
64
+ if (changed) {
65
+ entry.tags = next_tags
66
+ entries_updated += 1
67
+ }
68
+ }
69
+ }
70
+
71
+ if (entries_updated > 0) {
72
+ await write_db(db)
73
+ }
74
+
75
+ return {
76
+ entries_updated,
77
+ tags: collect_tag_stats(db),
78
+ }
79
+ }
@@ -0,0 +1,56 @@
1
+ import { DB_VERSION } from "@/lib/config";
2
+ import { migrate_json_db_to_version_three } from "@/lib/migrate_json_db_to_version_three";
3
+ import { migrate_json_db_to_version_two } from "@/lib/migrate_json_db_to_version_two";
4
+ import { type JSONTimeTrackerDB } from "@/lib/types";
5
+
6
+ const MIGRATIONS_BY_TARGET_VERSION: Record<
7
+ number,
8
+ (json_db: JSONTimeTrackerDB) => JSONTimeTrackerDB
9
+ > = {
10
+ 2: migrate_json_db_to_version_two,
11
+ 3: migrate_json_db_to_version_three,
12
+ };
13
+
14
+ export interface MigrateJsonDbResult {
15
+ did_migrate: boolean;
16
+ json_db: JSONTimeTrackerDB;
17
+ }
18
+
19
+ /**
20
+ * Runs sequential migrations until the JSON DB reaches the current version.
21
+ */
22
+ export function migrate_json_db(
23
+ json_db_input: JSONTimeTrackerDB,
24
+ ): MigrateJsonDbResult {
25
+ let json_db = json_db_input;
26
+ let did_migrate = false;
27
+ let current_version = json_db.version === undefined ? 1 : json_db.version;
28
+
29
+ if (current_version > DB_VERSION || current_version < 1) {
30
+ throw new Error(`Unknown DB version ${json_db.version}, cannot load.`);
31
+ }
32
+
33
+ while (current_version < DB_VERSION) {
34
+ const target_version = current_version + 1;
35
+ const migration = MIGRATIONS_BY_TARGET_VERSION[target_version];
36
+
37
+ if (migration === undefined) {
38
+ throw new Error(
39
+ `Missing migration from version ${current_version} to ${target_version}.`,
40
+ );
41
+ }
42
+
43
+ json_db = migration(json_db);
44
+
45
+ if (json_db.version !== target_version) {
46
+ throw new Error(
47
+ `Invalid migration output for version ${target_version}.`,
48
+ );
49
+ }
50
+
51
+ current_version = target_version;
52
+ did_migrate = true;
53
+ }
54
+
55
+ return { json_db, did_migrate };
56
+ }
@@ -0,0 +1,51 @@
1
+ import { type JSONTimeTrackerDB } from "@/lib/types";
2
+
3
+ /**
4
+ * Adds a notes array to each entry for DB version 3.
5
+ */
6
+ export function migrate_json_db_to_version_three(
7
+ json_db: JSONTimeTrackerDB,
8
+ ): JSONTimeTrackerDB {
9
+ const {
10
+ sheets: json_sheets,
11
+ version: json_version,
12
+ activeSheetName: json_active_sheet_name,
13
+ } = json_db;
14
+
15
+ if (json_version !== 2 && json_version !== undefined) {
16
+ throw new Error(
17
+ `DB is version ${json_version}, cannot migrate to version 3.`,
18
+ );
19
+ }
20
+
21
+ return {
22
+ version: 3,
23
+ activeSheetName: json_active_sheet_name,
24
+ sheets: json_sheets.map(
25
+ ({
26
+ name: json_name,
27
+ entries: json_entries,
28
+ activeEntryID: json_active_entry_id,
29
+ }) => ({
30
+ name: json_name,
31
+ activeEntryID: json_active_entry_id,
32
+ entries: json_entries.map(
33
+ ({
34
+ id: json_id,
35
+ start: json_start,
36
+ end: json_end,
37
+ description: json_description,
38
+ tags: json_tags,
39
+ }) => ({
40
+ notes: [],
41
+ tags: json_tags,
42
+ id: json_id,
43
+ end: json_end,
44
+ start: json_start,
45
+ description: json_description,
46
+ }),
47
+ ),
48
+ }),
49
+ ),
50
+ };
51
+ }
@@ -0,0 +1,50 @@
1
+ import { type JSONTimeTrackerDB } from "@/lib/types";
2
+
3
+ /**
4
+ * Adds empty tag and note arrays to each entry for DB version 2.
5
+ */
6
+ export function migrate_json_db_to_version_two(
7
+ json_db: JSONTimeTrackerDB,
8
+ ): JSONTimeTrackerDB {
9
+ const {
10
+ sheets: json_sheets,
11
+ version: json_version,
12
+ activeSheetName: json_active_sheet_name,
13
+ } = json_db;
14
+
15
+ if (json_version !== 1 && json_version !== undefined) {
16
+ throw new Error(
17
+ `DB is version ${json_version}, cannot migrate to version 2.`,
18
+ );
19
+ }
20
+
21
+ return {
22
+ version: 2,
23
+ activeSheetName: json_active_sheet_name,
24
+ sheets: json_sheets.map(
25
+ ({
26
+ name: json_name,
27
+ entries: json_entries,
28
+ activeEntryID: json_active_entry_id,
29
+ }) => ({
30
+ name: json_name,
31
+ activeEntryID: json_active_entry_id,
32
+ entries: json_entries.map(
33
+ ({
34
+ id: json_id,
35
+ start: json_start,
36
+ end: json_end,
37
+ description: json_description,
38
+ }) => ({
39
+ tags: [],
40
+ notes: [],
41
+ id: json_id,
42
+ end: json_end,
43
+ start: json_start,
44
+ description: json_description,
45
+ }),
46
+ ),
47
+ }),
48
+ ),
49
+ };
50
+ }
@@ -0,0 +1,152 @@
1
+ import { gen_sheet } from "@/lib/gen_db";
2
+ import { get_sheet } from "@/lib/get_sheet";
3
+ import { read_db } from "@/lib/read_db";
4
+ import { write_db } from "@/lib/write_db";
5
+ import { type TimeSheetEntry } from "@/lib/types";
6
+
7
+ export interface MoveEntryRef {
8
+ sheet_name: string;
9
+ entry_id: number;
10
+ }
11
+
12
+ export interface MoveEntriesToSheetArgs {
13
+ entries: MoveEntryRef[];
14
+ target_sheet_name: string;
15
+ }
16
+
17
+ interface PendingMove {
18
+ source_sheet_name: string;
19
+ entry: TimeSheetEntry;
20
+ is_active: boolean;
21
+ }
22
+
23
+ /**
24
+ * Moves multiple entries to another sheet in a single database write.
25
+ */
26
+ export async function move_entries_to_sheet(
27
+ args: MoveEntriesToSheetArgs,
28
+ ): Promise<void> {
29
+ const { entries: entry_refs, target_sheet_name } = args;
30
+ const trimmed_target = target_sheet_name.trim();
31
+
32
+ if (entry_refs.length === 0) {
33
+ throw new Error("No entries selected");
34
+ }
35
+
36
+ if (trimmed_target.length === 0) {
37
+ throw new Error("Target sheet name is required");
38
+ }
39
+
40
+ const db = await read_db();
41
+ const pending_moves: PendingMove[] = [];
42
+ const seen = new Set<string>();
43
+
44
+ for (const { entry_id, sheet_name } of entry_refs) {
45
+ const key = `${sheet_name}:${entry_id}`;
46
+
47
+ if (seen.has(key)) {
48
+ continue;
49
+ }
50
+
51
+ seen.add(key);
52
+
53
+ if (sheet_name === trimmed_target) {
54
+ continue;
55
+ }
56
+
57
+ const source_sheet = get_sheet(db, sheet_name);
58
+ const entry = source_sheet.entries.find(({ id }) => id === entry_id);
59
+
60
+ if (entry === undefined) {
61
+ throw new Error(`Entry ${entry_id} not found in sheet ${sheet_name}`);
62
+ }
63
+
64
+ pending_moves.push({
65
+ source_sheet_name: sheet_name,
66
+ entry: {
67
+ ...entry,
68
+ notes: [...entry.notes],
69
+ tags: [...entry.tags],
70
+ },
71
+ is_active: source_sheet.activeEntryID === entry_id,
72
+ });
73
+ }
74
+
75
+ if (pending_moves.length === 0) {
76
+ throw new Error("All selected entries are already on that sheet");
77
+ }
78
+
79
+ const active_moves = pending_moves.filter(({ is_active }) => is_active);
80
+
81
+ if (active_moves.length > 1) {
82
+ throw new Error("Cannot move multiple active entries at once");
83
+ }
84
+
85
+ const target_exists = db.sheets.some(({ name }) => name === trimmed_target);
86
+ const target_sheet = target_exists
87
+ ? get_sheet(db, trimmed_target)
88
+ : (() => {
89
+ const created = gen_sheet(trimmed_target);
90
+ db.sheets.push(created);
91
+ return created;
92
+ })();
93
+
94
+ if (active_moves.length === 1 && target_sheet.activeEntryID !== null) {
95
+ const active_entry = target_sheet.entries.find(
96
+ ({ id }) => id === target_sheet.activeEntryID,
97
+ );
98
+
99
+ if (active_entry !== undefined) {
100
+ throw new Error(
101
+ `Sheet ${trimmed_target} already has an active entry (${active_entry.description})`,
102
+ );
103
+ }
104
+ }
105
+
106
+ const by_source = new Map<string, PendingMove[]>();
107
+
108
+ for (const move of pending_moves) {
109
+ const list = by_source.get(move.source_sheet_name) ?? [];
110
+ list.push(move);
111
+ by_source.set(move.source_sheet_name, list);
112
+ }
113
+
114
+ for (const [source_sheet_name, moves] of by_source) {
115
+ const source_sheet = get_sheet(db, source_sheet_name);
116
+ const ids_to_remove = moves
117
+ .map(({ entry }) => entry.id)
118
+ .sort((left, right) => right - left);
119
+
120
+ for (const entry_id of ids_to_remove) {
121
+ const entry_index = source_sheet.entries.findIndex(
122
+ ({ id }) => id === entry_id,
123
+ );
124
+
125
+ if (entry_index === -1) {
126
+ continue;
127
+ }
128
+
129
+ source_sheet.entries.splice(entry_index, 1);
130
+
131
+ if (source_sheet.activeEntryID === entry_id) {
132
+ source_sheet.activeEntryID = null;
133
+ }
134
+ }
135
+ }
136
+
137
+ for (const move of pending_moves) {
138
+ const new_id = target_sheet.entries.length;
139
+
140
+ target_sheet.entries.push({
141
+ ...move.entry,
142
+ id: new_id,
143
+ });
144
+
145
+ if (move.is_active) {
146
+ target_sheet.activeEntryID = new_id;
147
+ db.activeSheetName = trimmed_target;
148
+ }
149
+ }
150
+
151
+ await write_db(db);
152
+ }
@@ -0,0 +1,82 @@
1
+ import { gen_sheet } from "@/lib/gen_db";
2
+ import { get_sheet } from "@/lib/get_sheet";
3
+ import { read_db } from "@/lib/read_db";
4
+ import { write_db } from "@/lib/write_db";
5
+ import { type TimeSheetEntry } from "@/lib/types";
6
+
7
+ export interface MoveEntryToSheetArgs {
8
+ sheet_name: string;
9
+ entry_id: number;
10
+ target_sheet_name: string;
11
+ }
12
+
13
+ /**
14
+ * Moves an entry from one sheet to another, preserving active status when applicable.
15
+ */
16
+ export async function move_entry_to_sheet(
17
+ args: MoveEntryToSheetArgs,
18
+ ): Promise<void> {
19
+ const { entry_id, sheet_name, target_sheet_name } = args;
20
+ const trimmed_target = target_sheet_name.trim();
21
+
22
+ if (trimmed_target.length === 0) {
23
+ throw new Error("Target sheet name is required");
24
+ }
25
+
26
+ if (sheet_name === trimmed_target) {
27
+ throw new Error("Entry is already on that sheet");
28
+ }
29
+
30
+ const db = await read_db();
31
+ const source_sheet = get_sheet(db, sheet_name);
32
+ const entry_index = source_sheet.entries.findIndex(({ id }) => id === entry_id);
33
+
34
+ if (entry_index === -1) {
35
+ throw new Error(`Entry ${entry_id} not found in sheet ${sheet_name}`);
36
+ }
37
+
38
+ const entry = source_sheet.entries[entry_index];
39
+ const is_active = source_sheet.activeEntryID === entry_id;
40
+
41
+ const target_exists = db.sheets.some(({ name }) => name === trimmed_target);
42
+ const target_sheet = target_exists
43
+ ? get_sheet(db, trimmed_target)
44
+ : (() => {
45
+ const created = gen_sheet(trimmed_target);
46
+ db.sheets.push(created);
47
+ return created;
48
+ })();
49
+
50
+ if (target_sheet.activeEntryID !== null) {
51
+ const active_entry = target_sheet.entries.find(
52
+ ({ id }) => id === target_sheet.activeEntryID,
53
+ );
54
+
55
+ if (active_entry !== undefined) {
56
+ throw new Error(
57
+ `Sheet ${trimmed_target} already has an active entry (${active_entry.description})`,
58
+ );
59
+ }
60
+ }
61
+
62
+ source_sheet.entries.splice(entry_index, 1);
63
+
64
+ if (is_active) {
65
+ source_sheet.activeEntryID = null;
66
+ }
67
+
68
+ const new_id = target_sheet.entries.length;
69
+ const moved_entry: TimeSheetEntry = {
70
+ ...entry,
71
+ id: new_id,
72
+ };
73
+
74
+ target_sheet.entries.push(moved_entry);
75
+
76
+ if (is_active) {
77
+ target_sheet.activeEntryID = new_id;
78
+ db.activeSheetName = trimmed_target;
79
+ }
80
+
81
+ await write_db(db);
82
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Normalizes user input into a stored @tag token.
3
+ */
4
+ export function normalize_stored_tag(tag: string): string {
5
+ const without_at = tag.trim().replace(/^@+/, '')
6
+
7
+ if (without_at.length === 0) {
8
+ throw new Error('Tag name must not be empty.')
9
+ }
10
+
11
+ if (!/^\w+$/.test(without_at)) {
12
+ throw new Error('Tags may only contain letters, numbers, and underscores.')
13
+ }
14
+
15
+ return `@${without_at}`
16
+ }
@@ -0,0 +1,47 @@
1
+ type SettingsSavedListener = (message: string) => void
2
+
3
+ const listeners = new Set<SettingsSavedListener>()
4
+
5
+ export const SETTINGS_SAVED_DEFAULT_MESSAGE = 'Settings saved'
6
+
7
+ let debounce_timer: ReturnType<typeof setTimeout> | null = null
8
+ let pending_message = SETTINGS_SAVED_DEFAULT_MESSAGE
9
+
10
+ /**
11
+ * Subscribes to settings-saved toast events.
12
+ */
13
+ export function subscribe_settings_saved(
14
+ listener: SettingsSavedListener,
15
+ ): () => void {
16
+ listeners.add(listener)
17
+
18
+ return () => {
19
+ listeners.delete(listener)
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Queues a short debounced “settings saved” toast (settings UI only).
25
+ */
26
+ export function notify_settings_saved(
27
+ message: string = SETTINGS_SAVED_DEFAULT_MESSAGE,
28
+ ): void {
29
+ if (typeof window === 'undefined') {
30
+ return
31
+ }
32
+
33
+ pending_message = message
34
+
35
+ if (debounce_timer !== null) {
36
+ clearTimeout(debounce_timer)
37
+ }
38
+
39
+ debounce_timer = setTimeout(() => {
40
+ debounce_timer = null
41
+ const next_message = pending_message
42
+
43
+ listeners.forEach((listener) => {
44
+ listener(next_message)
45
+ })
46
+ }, 200)
47
+ }
@@ -0,0 +1,21 @@
1
+ import {
2
+ DEFAULT_SHEET_SESSION_MODE_DEFAULT,
3
+ type DefaultSheetSessionMode,
4
+ } from '@/lib/types/ui_settings'
5
+
6
+ /**
7
+ * Parses a stored default sheet session mode value.
8
+ */
9
+ export function parse_default_sheet_session_mode(
10
+ value: string | undefined | null,
11
+ ): DefaultSheetSessionMode {
12
+ if (
13
+ value === 'last_viewed' ||
14
+ value === 'active_timer' ||
15
+ value === 'fixed'
16
+ ) {
17
+ return value
18
+ }
19
+
20
+ return DEFAULT_SHEET_SESSION_MODE_DEFAULT
21
+ }
@@ -0,0 +1,23 @@
1
+ import { type TimeSheetEntry } from "@/lib/types";
2
+
3
+ /**
4
+ * Parses a check-in description, extracting @tags from the text.
5
+ */
6
+ export function parse_entry_from_input(
7
+ id: number,
8
+ input: string,
9
+ start?: Date,
10
+ end?: Date | null,
11
+ ): TimeSheetEntry {
12
+ const tags = input.match(/@\w+/g) ?? [];
13
+ const description = input.split(/@\w+/).join(" ").trim();
14
+
15
+ return {
16
+ id,
17
+ tags,
18
+ notes: [],
19
+ description,
20
+ start: start ?? new Date(),
21
+ end: end ?? null,
22
+ };
23
+ }
@@ -0,0 +1,23 @@
1
+ import parseDate, { InvalidInputError } from "time-speak";
2
+
3
+ /**
4
+ * Parses a natural-language time expression into a Date.
5
+ */
6
+ export function parse_natural_language_date(expression: string): Date {
7
+ const trimmed = expression.trim();
8
+
9
+ if (trimmed.length === 0) {
10
+ throw new Error("Time expression is required");
11
+ }
12
+
13
+ try {
14
+ const parsed = parseDate(trimmed);
15
+ return new Date(+parsed);
16
+ } catch (error: unknown) {
17
+ if (error instanceof InvalidInputError) {
18
+ throw new Error(`Could not parse time: ${trimmed}`, { cause: error });
19
+ }
20
+
21
+ throw error;
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import { type ReportingSourceSheet } from '@/lib/types/reporting'
2
+ import { type TimeSheet } from '@/lib/types'
3
+
4
+ /**
5
+ * Restores time sheets from reporting source payloads.
6
+ */
7
+ export function parse_reporting_source_sheets(
8
+ source_sheets: ReportingSourceSheet[],
9
+ ): TimeSheet[] {
10
+ return source_sheets.map((sheet) => ({
11
+ name: sheet.name,
12
+ activeEntryID: sheet.activeEntryID,
13
+ entries: sheet.entries.map((entry) => ({
14
+ id: entry.id,
15
+ start: new Date(entry.start),
16
+ end: entry.end === null ? null : new Date(entry.end),
17
+ description: '',
18
+ tags: [],
19
+ notes: [],
20
+ })),
21
+ }))
22
+ }
@@ -0,0 +1,30 @@
1
+ import { is_idle_sheet_report } from '@/lib/is_idle_sheet_report'
2
+ import { type SheetReportStats } from '@/lib/types/reporting'
3
+
4
+ export interface PartitionedSheetReportStats {
5
+ activeSheets: SheetReportStats[]
6
+ idleSheets: SheetReportStats[]
7
+ }
8
+
9
+ /**
10
+ * Splits sheet report stats into tracked and empty or idle groups.
11
+ */
12
+ export function partition_sheet_report_stats(
13
+ sheets: SheetReportStats[],
14
+ ): PartitionedSheetReportStats {
15
+ const active_sheets: SheetReportStats[] = []
16
+ const idle_sheets: SheetReportStats[] = []
17
+
18
+ for (const sheet of sheets) {
19
+ if (is_idle_sheet_report(sheet)) {
20
+ idle_sheets.push(sheet)
21
+ } else {
22
+ active_sheets.push(sheet)
23
+ }
24
+ }
25
+
26
+ return {
27
+ activeSheets: active_sheets,
28
+ idleSheets: idle_sheets,
29
+ }
30
+ }
@@ -0,0 +1,22 @@
1
+ import { type TrackerState } from "@/lib/types/tracker_state";
2
+
3
+ /**
4
+ * Sends a PATCH request to a tracker API route and returns updated state.
5
+ */
6
+ export async function patch_tracker_action(
7
+ path: string,
8
+ body: unknown,
9
+ ): Promise<TrackerState> {
10
+ const response = await fetch(path, {
11
+ method: "PATCH",
12
+ headers: { "Content-Type": "application/json" },
13
+ body: JSON.stringify(body),
14
+ });
15
+
16
+ if (!response.ok) {
17
+ const payload = (await response.json()) as { error?: string };
18
+ throw new Error(payload.error ?? "Request failed");
19
+ }
20
+
21
+ return (await response.json()) as TrackerState;
22
+ }