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.
- package/AGENTS.md +5 -0
- package/CHANGELOG.md +28 -0
- package/CLAUDE.md +1 -0
- package/README.md +36 -0
- package/app/api/backup/route.ts +39 -0
- package/app/api/entry/delete-bulk/route.ts +53 -0
- package/app/api/entry/move/route.ts +46 -0
- package/app/api/entry/move-bulk/route.ts +62 -0
- package/app/api/entry/route.ts +75 -0
- package/app/api/in/route.ts +38 -0
- package/app/api/note/route.ts +120 -0
- package/app/api/out/route.ts +31 -0
- package/app/api/sheet/route.ts +68 -0
- package/app/api/state/route.ts +16 -0
- package/app/api/tags/route.ts +75 -0
- package/app/color-palettes.css +260 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +140 -0
- package/app/layout.tsx +54 -0
- package/app/page.tsx +24 -0
- package/app/reporting/page.tsx +11 -0
- package/app/settings/data/page.tsx +9 -0
- package/app/settings/display/page.tsx +8 -0
- package/app/settings/page.tsx +12 -0
- package/app/settings/tags/page.tsx +13 -0
- package/bin/stt-ui.js +63 -0
- package/components/active-entry-panel.tsx +199 -0
- package/components/backup-restore-setting.tsx +168 -0
- package/components/check-in-form-collapsed-setting.tsx +44 -0
- package/components/check-in-form-collapsible.tsx +52 -0
- package/components/check-in-form.tsx +89 -0
- package/components/checkbox.tsx +75 -0
- package/components/checkout-button-group.tsx +73 -0
- package/components/chevron-icon.tsx +25 -0
- package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
- package/components/color-palette-setting.tsx +75 -0
- package/components/compact-lists-setting.tsx +42 -0
- package/components/confirm-before-checkout-setting.tsx +42 -0
- package/components/confirm-destructive-actions-setting.tsx +46 -0
- package/components/confirm-dialog-provider.tsx +71 -0
- package/components/confirm-dialog.tsx +90 -0
- package/components/data-settings-view.tsx +47 -0
- package/components/default-reporting-range-setting.tsx +56 -0
- package/components/default-reporting-sort-setting.tsx +45 -0
- package/components/default-sheet-session-setting.tsx +118 -0
- package/components/display-settings-view.tsx +75 -0
- package/components/duration-format-setting.tsx +40 -0
- package/components/entry-actions-menu.tsx +207 -0
- package/components/entry-edit-form.tsx +113 -0
- package/components/entry-list-bulk-bar.tsx +128 -0
- package/components/entry-list-sort-setting.tsx +41 -0
- package/components/entry-list.tsx +336 -0
- package/components/entry-notes-list.tsx +211 -0
- package/components/entry-tag-filter.tsx +99 -0
- package/components/format_datetime_hint.ts +8 -0
- package/components/format_time.ts +10 -0
- package/components/general-settings-view.tsx +40 -0
- package/components/hamburger-icon.tsx +21 -0
- package/components/note-edit-form.tsx +77 -0
- package/components/note-form.tsx +109 -0
- package/components/pencil-icon.tsx +21 -0
- package/components/reporting-date-range-picker.tsx +121 -0
- package/components/reporting-sort-controls.tsx +53 -0
- package/components/reporting-view.tsx +340 -0
- package/components/setting-radio-group.tsx +79 -0
- package/components/settings-nav.tsx +66 -0
- package/components/settings-page-layout.tsx +53 -0
- package/components/settings-saved-toast.tsx +57 -0
- package/components/sheet-actions-menu.tsx +108 -0
- package/components/sheet-sidebar.tsx +196 -0
- package/components/tag-autocomplete-input.tsx +183 -0
- package/components/tag-filter-mode-setting.tsx +47 -0
- package/components/tag-management-view.tsx +290 -0
- package/components/theme-mode-setting.tsx +44 -0
- package/components/theme-mode-system-listener.tsx +43 -0
- package/components/theme_switcher.tsx +38 -0
- package/components/time-format-setting.tsx +39 -0
- package/components/timer-in-title-setting.tsx +38 -0
- package/components/timer-show-seconds-setting.tsx +41 -0
- package/components/tracker-active-bar.tsx +76 -0
- package/components/tracker-app.tsx +338 -0
- package/components/tracker-breadcrumb.tsx +56 -0
- package/components/tracker-document-title.tsx +67 -0
- package/components/tracker-topbar.tsx +63 -0
- package/components/trash-icon.tsx +24 -0
- package/components/week-starts-on-setting.tsx +39 -0
- package/eslint.config.mjs +18 -0
- package/lib/add_note_to_entry.ts +65 -0
- package/lib/api_error_response.ts +10 -0
- package/lib/apply_accent_color.ts +12 -0
- package/lib/apply_color_palette.ts +12 -0
- package/lib/apply_compact_lists.ts +9 -0
- package/lib/apply_tag_autocomplete_selection.ts +26 -0
- package/lib/apply_theme.ts +8 -0
- package/lib/build_reporting_stats.ts +55 -0
- package/lib/build_resume_description.ts +15 -0
- package/lib/check_in_entry.ts +81 -0
- package/lib/check_out_entry.ts +75 -0
- package/lib/collect_known_tags.ts +22 -0
- package/lib/collect_tag_stats.ts +27 -0
- package/lib/collect_tags_from_entries.ts +35 -0
- package/lib/config.ts +9 -0
- package/lib/convert_json_db.ts +49 -0
- package/lib/delete_entries.ts +62 -0
- package/lib/delete_entry.ts +29 -0
- package/lib/delete_note_on_entry.ts +42 -0
- package/lib/delete_sheet.ts +30 -0
- package/lib/delete_tracker_action.ts +22 -0
- package/lib/edit_entry.ts +56 -0
- package/lib/edit_note_on_entry.ts +49 -0
- package/lib/ensure_dir_exists.ts +22 -0
- package/lib/entry_matches_tag_filter.ts +26 -0
- package/lib/fetch_tracker_state.ts +15 -0
- package/lib/filter_entries_by_tags.ts +20 -0
- package/lib/filter_known_tags.ts +20 -0
- package/lib/find_all_serialized_active_entries.ts +28 -0
- package/lib/find_serialized_active_entry.ts +12 -0
- package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
- package/lib/find_sheet_with_active_entry.ts +16 -0
- package/lib/format_display_tag.ts +6 -0
- package/lib/format_duration.ts +45 -0
- package/lib/gen_db.ts +43 -0
- package/lib/get_active_panel_class_name.ts +20 -0
- package/lib/get_average_entry_ms.ts +13 -0
- package/lib/get_button_class_name.ts +24 -0
- package/lib/get_check_out_confirm_dialog.ts +19 -0
- package/lib/get_clipped_entry_duration_ms.ts +18 -0
- package/lib/get_compact_lists_snapshot.ts +15 -0
- package/lib/get_date_range_ms_from_inputs.ts +31 -0
- package/lib/get_delete_entries_confirm_dialog.ts +21 -0
- package/lib/get_delete_entry_confirm_dialog.ts +19 -0
- package/lib/get_delete_note_confirm_dialog.ts +21 -0
- package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
- package/lib/get_entry_duration_ms.ts +14 -0
- package/lib/get_entry_row_key.ts +8 -0
- package/lib/get_initial_preferred_sheet_name.ts +34 -0
- package/lib/get_initial_reporting_range_inputs.ts +31 -0
- package/lib/get_input_class_name.ts +15 -0
- package/lib/get_merge_tags_confirm_dialog.ts +25 -0
- package/lib/get_period_range_ms.ts +43 -0
- package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
- package/lib/get_reporting_period_totals.ts +39 -0
- package/lib/get_reporting_stats.ts +25 -0
- package/lib/get_restore_db_confirm_dialog.ts +14 -0
- package/lib/get_running_entry_key.ts +8 -0
- package/lib/get_serialized_entries_total_ms.ts +10 -0
- package/lib/get_sheet.ts +14 -0
- package/lib/get_sheet_report_stats.ts +22 -0
- package/lib/get_sheet_report_stats_for_range.ts +46 -0
- package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
- package/lib/get_sheets_duration_in_range.ts +27 -0
- package/lib/get_tag_autocomplete_context.ts +32 -0
- package/lib/get_theme_snapshot.ts +16 -0
- package/lib/get_tracker_state.ts +67 -0
- package/lib/has_string_value.ts +6 -0
- package/lib/is_entry_in_day.ts +15 -0
- package/lib/is_idle_sheet_report.ts +8 -0
- package/lib/is_json_time_tracker_db.ts +14 -0
- package/lib/merge_tags_across_db.ts +79 -0
- package/lib/migrate_json_db.ts +56 -0
- package/lib/migrate_json_db_to_version_three.ts +51 -0
- package/lib/migrate_json_db_to_version_two.ts +50 -0
- package/lib/move_entries_to_sheet.ts +152 -0
- package/lib/move_entry_to_sheet.ts +82 -0
- package/lib/normalize_stored_tag.ts +16 -0
- package/lib/notify_settings_saved.ts +47 -0
- package/lib/parse_default_sheet_session_mode.ts +21 -0
- package/lib/parse_entry_from_input.ts +23 -0
- package/lib/parse_natural_language_date.ts +23 -0
- package/lib/parse_reporting_source_sheets.ts +22 -0
- package/lib/partition_sheet_report_stats.ts +30 -0
- package/lib/patch_tracker_action.ts +22 -0
- package/lib/persist_ui_preference.ts +18 -0
- package/lib/post_tracker_action.ts +22 -0
- package/lib/preferences/accent_color_preference.ts +21 -0
- package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
- package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
- package/lib/preferences/color_palette_preference.ts +21 -0
- package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
- package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
- package/lib/preferences/default_reporting_range_preference.ts +21 -0
- package/lib/preferences/default_reporting_sort_preference.ts +24 -0
- package/lib/preferences/duration_format_preference.ts +19 -0
- package/lib/preferences/entry_list_sort_preference.ts +21 -0
- package/lib/preferences/tag_filter_mode_preference.ts +18 -0
- package/lib/preferences/theme_mode_preference.ts +18 -0
- package/lib/preferences/time_format_preference.ts +18 -0
- package/lib/preferences/timer_in_title_preference.ts +18 -0
- package/lib/preferences/timer_show_seconds_preference.ts +19 -0
- package/lib/preferences/week_starts_on_preference.ts +19 -0
- package/lib/prompt_check_out_at.ts +17 -0
- package/lib/prompt_entry_note.ts +14 -0
- package/lib/prune_sheet_tag_filter.ts +27 -0
- package/lib/read_db.ts +49 -0
- package/lib/read_db_backup_contents.ts +22 -0
- package/lib/read_document_compact_lists.ts +12 -0
- package/lib/read_document_theme.ts +14 -0
- package/lib/read_sheet_tag_filter.ts +26 -0
- package/lib/read_stored_active_sheet.ts +14 -0
- package/lib/read_stored_compact_lists.ts +24 -0
- package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
- package/lib/read_stored_default_sheet_session_mode.ts +18 -0
- package/lib/read_stored_sheet_tag_filters.ts +28 -0
- package/lib/read_stored_theme.ts +18 -0
- package/lib/rename_sheet.ts +39 -0
- package/lib/rename_tag_across_db.ts +19 -0
- package/lib/resolve_active_sheet_name.ts +36 -0
- package/lib/resolve_session_preferred_sheet.ts +37 -0
- package/lib/resolve_theme.ts +18 -0
- package/lib/resolve_theme_mode_to_theme.ts +19 -0
- package/lib/restore_db_from_uploaded_json.ts +24 -0
- package/lib/serialize_entry.ts +27 -0
- package/lib/serialize_reporting_source_sheets.ts +19 -0
- package/lib/serialize_sheet_entries.ts +18 -0
- package/lib/set_accent_color.ts +12 -0
- package/lib/set_active_sheet.ts +18 -0
- package/lib/set_color_palette.ts +12 -0
- package/lib/set_compact_lists.ts +12 -0
- package/lib/set_default_sheet_fixed_name.ts +8 -0
- package/lib/set_default_sheet_session_mode.ts +11 -0
- package/lib/set_sheet_tag_filter.ts +13 -0
- package/lib/set_theme_mode.ts +19 -0
- package/lib/sheet_tag_filter_snapshots.ts +48 -0
- package/lib/sort_serialized_entries.ts +35 -0
- package/lib/sort_sheet_report_stats.ts +43 -0
- package/lib/subscribe_compact_lists.ts +25 -0
- package/lib/subscribe_sheet_tag_filters.ts +28 -0
- package/lib/subscribe_theme.ts +23 -0
- package/lib/sync_active_sheet_preference.ts +19 -0
- package/lib/tags_are_equal.ts +12 -0
- package/lib/theme_init_script.ts +11 -0
- package/lib/toggle_sheet_tag_filter.ts +28 -0
- package/lib/toggle_theme.ts +20 -0
- package/lib/types/confirm_dialog.ts +9 -0
- package/lib/types/data.ts +16 -0
- package/lib/types/generic_data.ts +25 -0
- package/lib/types/index.ts +2 -0
- package/lib/types/reporting.ts +59 -0
- package/lib/types/tag_management.ts +7 -0
- package/lib/types/theme.ts +3 -0
- package/lib/types/tracker_state.ts +39 -0
- package/lib/types/ui_preferences.ts +104 -0
- package/lib/types/ui_settings.ts +17 -0
- package/lib/ui_preference_store.ts +80 -0
- package/lib/ui_settings_init_script.ts +33 -0
- package/lib/use_check_in_form_collapsed.ts +18 -0
- package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
- package/lib/use_confirm_before_checkout.ts +18 -0
- package/lib/use_confirm_destructive_actions.ts +18 -0
- package/lib/use_duration_format.ts +17 -0
- package/lib/use_entry_list_sort.ts +17 -0
- package/lib/use_tag_filter_mode.ts +17 -0
- package/lib/use_time_format.ts +17 -0
- package/lib/use_timer_in_title.ts +18 -0
- package/lib/use_timer_show_seconds.ts +18 -0
- package/lib/use_week_starts_on.ts +17 -0
- package/lib/validate_entry_times.ts +12 -0
- package/lib/week_starts_on_to_index.ts +8 -0
- package/lib/write_active_sheet_preference.ts +28 -0
- package/lib/write_db.ts +20 -0
- package/lib/write_sheet_tag_filter.ts +20 -0
- package/lib/write_stored_compact_lists.ts +15 -0
- package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
- package/lib/write_stored_default_sheet_session_mode.ts +24 -0
- package/lib/write_stored_sheet_tag_filters.ts +18 -0
- package/lib/write_stored_theme.ts +12 -0
- package/next.config.ts +7 -0
- package/package.json +96 -0
- package/pnpm-workspace.yaml +7 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { CheckoutButtonGroup } from '@/components/checkout-button-group'
|
|
6
|
+
import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
|
|
7
|
+
import { EntryActionsMenu } from '@/components/entry-actions-menu'
|
|
8
|
+
import { EntryEditForm, type EntryEditFormValues } from '@/components/entry-edit-form'
|
|
9
|
+
import { EntryNotesList } from '@/components/entry-notes-list'
|
|
10
|
+
import { NoteForm } from '@/components/note-form'
|
|
11
|
+
import { format_display_tag } from '@/lib/format_display_tag'
|
|
12
|
+
import { format_duration } from '@/lib/format_duration'
|
|
13
|
+
import { use_confirm_destructive_actions } from '@/lib/use_confirm_destructive_actions'
|
|
14
|
+
import { use_duration_format } from '@/lib/use_duration_format'
|
|
15
|
+
import { use_timer_show_seconds } from '@/lib/use_timer_show_seconds'
|
|
16
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
17
|
+
import { get_delete_entry_confirm_dialog } from '@/lib/get_delete_entry_confirm_dialog'
|
|
18
|
+
import { get_delete_note_confirm_dialog } from '@/lib/get_delete_note_confirm_dialog'
|
|
19
|
+
import { get_active_panel_class_name } from '@/lib/get_active_panel_class_name'
|
|
20
|
+
import {
|
|
21
|
+
type SerializedEntry,
|
|
22
|
+
type SerializedSheet,
|
|
23
|
+
} from '@/lib/types/tracker_state'
|
|
24
|
+
|
|
25
|
+
interface ActiveEntryPanelProps {
|
|
26
|
+
entry: SerializedEntry
|
|
27
|
+
sheets: SerializedSheet[]
|
|
28
|
+
in_bar?: boolean
|
|
29
|
+
on_check_out: (at?: string) => void
|
|
30
|
+
on_delete: () => void
|
|
31
|
+
on_edit: (values: EntryEditFormValues) => void
|
|
32
|
+
on_move: (target_sheet_name: string) => void
|
|
33
|
+
on_add_note: (text: string, at?: string) => void
|
|
34
|
+
on_edit_note: (timestamp: string, text: string) => void
|
|
35
|
+
on_delete_note: (timestamp: string) => void
|
|
36
|
+
is_pending: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tag_item_class =
|
|
40
|
+
'rounded-full bg-tag-bg px-2 py-0.5 text-xs text-tag-text'
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shows the running active entry with a live duration timer.
|
|
44
|
+
*/
|
|
45
|
+
export function ActiveEntryPanel({
|
|
46
|
+
entry,
|
|
47
|
+
sheets,
|
|
48
|
+
in_bar = false,
|
|
49
|
+
on_check_out,
|
|
50
|
+
on_delete,
|
|
51
|
+
on_edit,
|
|
52
|
+
on_move,
|
|
53
|
+
on_add_note,
|
|
54
|
+
on_edit_note,
|
|
55
|
+
on_delete_note,
|
|
56
|
+
is_pending,
|
|
57
|
+
}: ActiveEntryPanelProps) {
|
|
58
|
+
const { confirm } = use_confirm_dialog()
|
|
59
|
+
const confirm_destructive_actions = use_confirm_destructive_actions()
|
|
60
|
+
const duration_format = use_duration_format()
|
|
61
|
+
const show_seconds = use_timer_show_seconds()
|
|
62
|
+
const [duration_ms, set_duration_ms] = useState(entry.durationMs)
|
|
63
|
+
const [is_editing, set_is_editing] = useState(false)
|
|
64
|
+
const [is_adding_note, set_is_adding_note] = useState(false)
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
set_is_adding_note(false)
|
|
68
|
+
}, [entry.id, entry.sheetName])
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
set_duration_ms(entry.durationMs)
|
|
72
|
+
|
|
73
|
+
const interval = window.setInterval(() => {
|
|
74
|
+
set_duration_ms(Date.now() - new Date(entry.start).getTime())
|
|
75
|
+
}, 1000)
|
|
76
|
+
|
|
77
|
+
return () => window.clearInterval(interval)
|
|
78
|
+
}, [entry.durationMs, entry.start])
|
|
79
|
+
|
|
80
|
+
const panel_class = get_active_panel_class_name(in_bar, is_editing)
|
|
81
|
+
|
|
82
|
+
const handle_delete_note = async (timestamp: string): Promise<void> => {
|
|
83
|
+
const note = entry.notes.find((item) => item.timestamp === timestamp)
|
|
84
|
+
const confirmed = confirm_destructive_actions
|
|
85
|
+
? await confirm(get_delete_note_confirm_dialog(note?.text ?? ''))
|
|
86
|
+
: true
|
|
87
|
+
|
|
88
|
+
if (confirmed) {
|
|
89
|
+
on_delete_note(timestamp)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (is_editing) {
|
|
94
|
+
return (
|
|
95
|
+
<section className={panel_class}>
|
|
96
|
+
<EntryEditForm
|
|
97
|
+
entry={entry}
|
|
98
|
+
is_pending={is_pending}
|
|
99
|
+
in_active_panel
|
|
100
|
+
on_cancel={() => set_is_editing(false)}
|
|
101
|
+
on_save={(values) => {
|
|
102
|
+
on_edit(values)
|
|
103
|
+
set_is_editing(false)
|
|
104
|
+
}}
|
|
105
|
+
/>
|
|
106
|
+
</section>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<section className={panel_class}>
|
|
112
|
+
<div className="flex items-start justify-between gap-3">
|
|
113
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
114
|
+
{!in_bar ? (
|
|
115
|
+
<span className="self-start rounded-full bg-accent px-2 py-0.5 text-[0.68rem] font-bold uppercase leading-none tracking-wider text-accent-text-on">
|
|
116
|
+
Tracking
|
|
117
|
+
</span>
|
|
118
|
+
) : null}
|
|
119
|
+
<h2 className="m-0 text-xl font-[650] leading-tight tracking-tight">
|
|
120
|
+
{entry.description || 'Untitled entry'}
|
|
121
|
+
</h2>
|
|
122
|
+
</div>
|
|
123
|
+
<EntryActionsMenu
|
|
124
|
+
current_sheet_name={entry.sheetName}
|
|
125
|
+
sheets={sheets}
|
|
126
|
+
is_pending={is_pending}
|
|
127
|
+
on_edit={() => set_is_editing(true)}
|
|
128
|
+
on_show_add_note_form={() => set_is_adding_note(true)}
|
|
129
|
+
on_move={on_move}
|
|
130
|
+
on_delete={async () => {
|
|
131
|
+
const confirmed = confirm_destructive_actions
|
|
132
|
+
? await confirm(get_delete_entry_confirm_dialog(entry))
|
|
133
|
+
: true
|
|
134
|
+
|
|
135
|
+
if (confirmed) {
|
|
136
|
+
on_delete()
|
|
137
|
+
}
|
|
138
|
+
}}
|
|
139
|
+
/>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex items-end justify-between gap-4 max-[860px]:flex-col max-[860px]:items-stretch">
|
|
142
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
143
|
+
<p className="m-0 font-mono text-[2rem] font-medium leading-none tracking-tight text-accent">
|
|
144
|
+
{format_duration(duration_ms, duration_format, show_seconds)}
|
|
145
|
+
</p>
|
|
146
|
+
{entry.tags.length > 0 ? (
|
|
147
|
+
<ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
|
|
148
|
+
{entry.tags.map((tag) => (
|
|
149
|
+
<li key={tag} className={tag_item_class}>
|
|
150
|
+
{format_display_tag(tag)}
|
|
151
|
+
</li>
|
|
152
|
+
))}
|
|
153
|
+
</ul>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
<div
|
|
157
|
+
className={`inline-flex shrink-0 items-center gap-2 ${in_bar ? 'min-w-0 max-[860px]:w-full max-[860px]:justify-end' : 'min-w-30 max-[860px]:w-full max-[860px]:justify-stretch'}`}
|
|
158
|
+
>
|
|
159
|
+
{!is_adding_note ? (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className={`${get_button_class_name('ghost')} max-[860px]:flex-1`}
|
|
163
|
+
disabled={is_pending}
|
|
164
|
+
onClick={() => set_is_adding_note(true)}
|
|
165
|
+
>
|
|
166
|
+
Add note
|
|
167
|
+
</button>
|
|
168
|
+
) : null}
|
|
169
|
+
<CheckoutButtonGroup
|
|
170
|
+
in_bar={in_bar}
|
|
171
|
+
is_pending={is_pending}
|
|
172
|
+
on_check_out={on_check_out}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<EntryNotesList
|
|
177
|
+
notes={entry.notes}
|
|
178
|
+
variant="panel"
|
|
179
|
+
in_bar={in_bar}
|
|
180
|
+
is_pending={is_pending}
|
|
181
|
+
on_edit_note={on_edit_note}
|
|
182
|
+
on_delete_note={handle_delete_note}
|
|
183
|
+
/>
|
|
184
|
+
{is_adding_note ? (
|
|
185
|
+
<NoteForm
|
|
186
|
+
in_active_panel
|
|
187
|
+
in_bar={in_bar}
|
|
188
|
+
allow_at
|
|
189
|
+
is_pending={is_pending}
|
|
190
|
+
on_cancel={() => set_is_adding_note(false)}
|
|
191
|
+
on_submit={(text, at) => {
|
|
192
|
+
on_add_note(text, at)
|
|
193
|
+
set_is_adding_note(false)
|
|
194
|
+
}}
|
|
195
|
+
/>
|
|
196
|
+
) : null}
|
|
197
|
+
</section>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRouter } from 'next/navigation'
|
|
4
|
+
import { useRef, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
|
|
7
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
8
|
+
import { get_restore_db_confirm_dialog } from '@/lib/get_restore_db_confirm_dialog'
|
|
9
|
+
|
|
10
|
+
interface BackupRestoreSettingProps {
|
|
11
|
+
db_path: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Downloads or restores the tracker database from Settings.
|
|
16
|
+
*/
|
|
17
|
+
export function BackupRestoreSetting({ db_path }: BackupRestoreSettingProps) {
|
|
18
|
+
const router = useRouter()
|
|
19
|
+
const { confirm } = use_confirm_dialog()
|
|
20
|
+
const file_input_ref = useRef<HTMLInputElement>(null)
|
|
21
|
+
const [error, set_error] = useState<string | null>(null)
|
|
22
|
+
const [status_message, set_status_message] = useState<string | null>(null)
|
|
23
|
+
const [is_downloading, set_is_downloading] = useState(false)
|
|
24
|
+
const [is_restoring, set_is_restoring] = useState(false)
|
|
25
|
+
|
|
26
|
+
const handle_download = async (): Promise<void> => {
|
|
27
|
+
set_is_downloading(true)
|
|
28
|
+
set_error(null)
|
|
29
|
+
set_status_message(null)
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch('/api/backup')
|
|
33
|
+
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
const body = (await response.json()) as { error?: string }
|
|
36
|
+
throw new Error(body.error ?? 'Download failed')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const blob = await response.blob()
|
|
40
|
+
const url = URL.createObjectURL(blob)
|
|
41
|
+
const link = document.createElement('a')
|
|
42
|
+
|
|
43
|
+
link.href = url
|
|
44
|
+
link.download = 'db.json'
|
|
45
|
+
link.click()
|
|
46
|
+
URL.revokeObjectURL(url)
|
|
47
|
+
set_status_message('Backup downloaded.')
|
|
48
|
+
} catch (download_error: unknown) {
|
|
49
|
+
set_error(
|
|
50
|
+
download_error instanceof Error
|
|
51
|
+
? download_error.message
|
|
52
|
+
: String(download_error),
|
|
53
|
+
)
|
|
54
|
+
} finally {
|
|
55
|
+
set_is_downloading(false)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handle_restore_click = (): void => {
|
|
60
|
+
file_input_ref.current?.click()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const handle_file_change = async (
|
|
64
|
+
event: React.ChangeEvent<HTMLInputElement>,
|
|
65
|
+
): Promise<void> => {
|
|
66
|
+
const file = event.target.files?.[0]
|
|
67
|
+
|
|
68
|
+
event.target.value = ''
|
|
69
|
+
|
|
70
|
+
if (file === undefined) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const confirmed = await confirm(get_restore_db_confirm_dialog())
|
|
75
|
+
|
|
76
|
+
if (!confirmed) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
set_is_restoring(true)
|
|
81
|
+
set_error(null)
|
|
82
|
+
set_status_message(null)
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const text = await file.text()
|
|
86
|
+
let uploaded: unknown
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
uploaded = JSON.parse(text)
|
|
90
|
+
} catch {
|
|
91
|
+
throw new Error('Invalid backup file: file is not valid JSON.')
|
|
92
|
+
}
|
|
93
|
+
const response = await fetch('/api/backup', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'Content-Type': 'application/json' },
|
|
96
|
+
body: JSON.stringify(uploaded),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (!response.ok) {
|
|
100
|
+
const body = (await response.json()) as { error?: string }
|
|
101
|
+
throw new Error(body.error ?? 'Restore failed')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
set_status_message('Backup restored. Opening tracker…')
|
|
105
|
+
router.push('/')
|
|
106
|
+
router.refresh()
|
|
107
|
+
} catch (restore_error: unknown) {
|
|
108
|
+
set_error(
|
|
109
|
+
restore_error instanceof Error
|
|
110
|
+
? restore_error.message
|
|
111
|
+
: String(restore_error),
|
|
112
|
+
)
|
|
113
|
+
} finally {
|
|
114
|
+
set_is_restoring(false)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const is_busy = is_downloading || is_restoring
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<div className="flex w-full flex-col gap-3">
|
|
122
|
+
<div className="flex flex-col gap-0.5">
|
|
123
|
+
<h2 className="m-0 text-[0.95rem] font-semibold">Backup and restore</h2>
|
|
124
|
+
<p className="m-0 text-[0.8rem] leading-snug text-muted">
|
|
125
|
+
Download a copy of your database or replace it with a previously saved
|
|
126
|
+
backup file.
|
|
127
|
+
</p>
|
|
128
|
+
</div>
|
|
129
|
+
<p
|
|
130
|
+
className="m-0 overflow-wrap-anywhere font-mono text-[0.65rem] leading-snug text-muted"
|
|
131
|
+
title={db_path}
|
|
132
|
+
>
|
|
133
|
+
{db_path}
|
|
134
|
+
</p>
|
|
135
|
+
<div className="flex flex-wrap gap-2">
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
className={get_button_class_name('ghost', 'small')}
|
|
139
|
+
disabled={is_busy}
|
|
140
|
+
onClick={() => void handle_download()}
|
|
141
|
+
>
|
|
142
|
+
{is_downloading ? 'Downloading…' : 'Download backup'}
|
|
143
|
+
</button>
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
className={get_button_class_name('danger', 'small')}
|
|
147
|
+
disabled={is_busy}
|
|
148
|
+
onClick={handle_restore_click}
|
|
149
|
+
>
|
|
150
|
+
{is_restoring ? 'Restoring…' : 'Restore from file'}
|
|
151
|
+
</button>
|
|
152
|
+
<input
|
|
153
|
+
ref={file_input_ref}
|
|
154
|
+
type="file"
|
|
155
|
+
accept="application/json,.json"
|
|
156
|
+
className="hidden"
|
|
157
|
+
onChange={(event) => void handle_file_change(event)}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
{status_message !== null ? (
|
|
161
|
+
<p className="m-0 text-[0.82rem] text-accent">{status_message}</p>
|
|
162
|
+
) : null}
|
|
163
|
+
{error !== null ? (
|
|
164
|
+
<p className="m-0 text-[0.82rem] text-danger">{error}</p>
|
|
165
|
+
) : null}
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { check_in_form_collapsed_preference } from '@/lib/preferences/check_in_form_collapsed_preference'
|
|
6
|
+
import { persist_ui_preference } from '@/lib/persist_ui_preference'
|
|
7
|
+
|
|
8
|
+
const set_check_in_form_collapsed = (collapsed: boolean): void => {
|
|
9
|
+
persist_ui_preference(
|
|
10
|
+
check_in_form_collapsed_preference,
|
|
11
|
+
collapsed ? 'true' : 'false',
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Setting: collapse the check-in form into a single button by default.
|
|
17
|
+
*/
|
|
18
|
+
export function CheckInFormCollapsedSetting() {
|
|
19
|
+
const value = useSyncExternalStore(
|
|
20
|
+
check_in_form_collapsed_preference.subscribe,
|
|
21
|
+
check_in_form_collapsed_preference.get_snapshot,
|
|
22
|
+
check_in_form_collapsed_preference.get_server_snapshot,
|
|
23
|
+
)
|
|
24
|
+
const is_collapsed = value === 'true'
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<label className="flex w-full cursor-pointer items-center gap-2.5">
|
|
28
|
+
<input
|
|
29
|
+
type="checkbox"
|
|
30
|
+
className="shrink-0"
|
|
31
|
+
checked={is_collapsed}
|
|
32
|
+
onChange={(event) => set_check_in_form_collapsed(event.target.checked)}
|
|
33
|
+
/>
|
|
34
|
+
<span className="flex flex-col gap-0.5">
|
|
35
|
+
<span className="text-[0.95rem] font-semibold">
|
|
36
|
+
Collapse check-in form
|
|
37
|
+
</span>
|
|
38
|
+
<span className="text-[0.8rem] leading-snug text-muted">
|
|
39
|
+
Show a single “Check in” button until clicked, instead of the full form.
|
|
40
|
+
</span>
|
|
41
|
+
</span>
|
|
42
|
+
</label>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { CheckInForm, type CheckInFormValues } from '@/components/check-in-form'
|
|
6
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
7
|
+
import { use_check_in_form_collapsed } from '@/lib/use_check_in_form_collapsed'
|
|
8
|
+
|
|
9
|
+
interface CheckInFormCollapsibleProps {
|
|
10
|
+
known_tags: string[]
|
|
11
|
+
is_pending: boolean
|
|
12
|
+
on_submit: (values: CheckInFormValues) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Renders the check-in form, collapsible to a button when the preference is set.
|
|
17
|
+
*/
|
|
18
|
+
export function CheckInFormCollapsible({
|
|
19
|
+
known_tags,
|
|
20
|
+
is_pending,
|
|
21
|
+
on_submit,
|
|
22
|
+
}: CheckInFormCollapsibleProps) {
|
|
23
|
+
const should_collapse_by_default = use_check_in_form_collapsed()
|
|
24
|
+
const [is_expanded, set_is_expanded] = useState(!should_collapse_by_default)
|
|
25
|
+
|
|
26
|
+
if (should_collapse_by_default && !is_expanded) {
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
className={`${get_button_class_name('primary')} self-start`}
|
|
31
|
+
disabled={is_pending}
|
|
32
|
+
onClick={() => set_is_expanded(true)}
|
|
33
|
+
>
|
|
34
|
+
Check in
|
|
35
|
+
</button>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<CheckInForm
|
|
41
|
+
known_tags={known_tags}
|
|
42
|
+
is_pending={is_pending}
|
|
43
|
+
on_submit={(values) => {
|
|
44
|
+
on_submit(values)
|
|
45
|
+
|
|
46
|
+
if (should_collapse_by_default) {
|
|
47
|
+
set_is_expanded(false)
|
|
48
|
+
}
|
|
49
|
+
}}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { type FormEvent, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { TagAutocompleteInput } from '@/components/tag-autocomplete-input'
|
|
6
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
7
|
+
import { get_input_class_name } from '@/lib/get_input_class_name'
|
|
8
|
+
|
|
9
|
+
export interface CheckInFormValues {
|
|
10
|
+
description: string
|
|
11
|
+
at?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CheckInFormProps {
|
|
15
|
+
known_tags: string[]
|
|
16
|
+
on_submit: (values: CheckInFormValues) => void
|
|
17
|
+
is_pending: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Form for starting a new time sheet entry.
|
|
22
|
+
*/
|
|
23
|
+
export function CheckInForm({
|
|
24
|
+
known_tags,
|
|
25
|
+
on_submit,
|
|
26
|
+
is_pending,
|
|
27
|
+
}: CheckInFormProps) {
|
|
28
|
+
const [description, set_description] = useState('')
|
|
29
|
+
const [at, set_at] = useState('')
|
|
30
|
+
|
|
31
|
+
const handle_submit = (event: FormEvent<HTMLFormElement>): void => {
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
const trimmed_description = description.trim()
|
|
34
|
+
|
|
35
|
+
if (trimmed_description.length === 0) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const trimmed_at = at.trim()
|
|
40
|
+
|
|
41
|
+
on_submit({
|
|
42
|
+
description: trimmed_description,
|
|
43
|
+
...(trimmed_at.length > 0 ? { at: trimmed_at } : {}),
|
|
44
|
+
})
|
|
45
|
+
set_description('')
|
|
46
|
+
set_at('')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<form
|
|
51
|
+
className="flex flex-col gap-2 rounded-lg border border-panel-border bg-panel p-[1.1rem] shadow-sm"
|
|
52
|
+
onSubmit={handle_submit}
|
|
53
|
+
>
|
|
54
|
+
<label className="text-[0.85rem] text-muted" htmlFor="check-in-description">
|
|
55
|
+
What are you working on?
|
|
56
|
+
</label>
|
|
57
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 max-[860px]:grid-cols-1">
|
|
58
|
+
<TagAutocompleteInput
|
|
59
|
+
id="check-in-description"
|
|
60
|
+
value={description}
|
|
61
|
+
known_tags={known_tags}
|
|
62
|
+
placeholder="e.g. crafting something @design"
|
|
63
|
+
disabled={is_pending}
|
|
64
|
+
autoFocus
|
|
65
|
+
on_change={set_description}
|
|
66
|
+
/>
|
|
67
|
+
<button
|
|
68
|
+
type="submit"
|
|
69
|
+
className={get_button_class_name('primary')}
|
|
70
|
+
disabled={is_pending || description.trim().length === 0}
|
|
71
|
+
>
|
|
72
|
+
Check in
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
<label className="text-[0.85rem] text-muted" htmlFor="check-in-at">
|
|
76
|
+
Start time{' '}
|
|
77
|
+
<span className="font-normal opacity-85">(optional, natural language)</span>
|
|
78
|
+
</label>
|
|
79
|
+
<input
|
|
80
|
+
id="check-in-at"
|
|
81
|
+
className={get_input_class_name()}
|
|
82
|
+
value={at}
|
|
83
|
+
onChange={(event) => set_at(event.target.value)}
|
|
84
|
+
placeholder="e.g. 30 minutes ago"
|
|
85
|
+
disabled={is_pending}
|
|
86
|
+
/>
|
|
87
|
+
</form>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef } from 'react'
|
|
4
|
+
|
|
5
|
+
interface CheckboxProps {
|
|
6
|
+
checked: boolean
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
indeterminate?: boolean
|
|
9
|
+
nested?: boolean
|
|
10
|
+
on_change: () => void
|
|
11
|
+
label?: string
|
|
12
|
+
aria_label?: string
|
|
13
|
+
className?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const root_base =
|
|
17
|
+
'inline-flex shrink-0 cursor-pointer items-center gap-1.5'
|
|
18
|
+
|
|
19
|
+
const control_class =
|
|
20
|
+
'relative block h-[0.85rem] w-[0.85rem] shrink-0 rounded-[0.2rem] border border-panel-border bg-input-bg box-border transition-[background-color,border-color] duration-150 peer-checked:border-accent peer-checked:bg-accent peer-indeterminate:border-accent peer-indeterminate:bg-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-input-focus-border peer-focus-visible:outline-offset-1 peer-disabled:cursor-not-allowed peer-disabled:opacity-55 after:absolute after:left-1/2 after:top-[44%] after:hidden after:h-[0.28rem] after:w-[0.45rem] after:-translate-x-1/2 after:-translate-y-[60%] after:rotate-[-45deg] after:border-b-[1.5px] after:border-l-[1.5px] after:border-accent-text-on after:content-[""] peer-checked:after:block peer-indeterminate:after:top-1/2 peer-indeterminate:after:block peer-indeterminate:after:h-[1.5px] peer-indeterminate:after:w-[0.45rem] peer-indeterminate:after:-translate-x-1/2 peer-indeterminate:after:-translate-y-1/2 peer-indeterminate:after:rotate-0 peer-indeterminate:after:border-0 peer-indeterminate:after:bg-accent-text-on'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Accessible custom-styled checkbox control.
|
|
24
|
+
*/
|
|
25
|
+
export function Checkbox({
|
|
26
|
+
checked,
|
|
27
|
+
disabled = false,
|
|
28
|
+
indeterminate = false,
|
|
29
|
+
nested = false,
|
|
30
|
+
on_change,
|
|
31
|
+
label,
|
|
32
|
+
aria_label,
|
|
33
|
+
className,
|
|
34
|
+
}: CheckboxProps) {
|
|
35
|
+
const input_ref = useRef<HTMLInputElement>(null)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (input_ref.current !== null) {
|
|
39
|
+
input_ref.current.indeterminate = indeterminate
|
|
40
|
+
}
|
|
41
|
+
}, [indeterminate])
|
|
42
|
+
|
|
43
|
+
const root_class =
|
|
44
|
+
className === undefined ? root_base : `${root_base} ${className}`
|
|
45
|
+
|
|
46
|
+
const control = (
|
|
47
|
+
<>
|
|
48
|
+
<input
|
|
49
|
+
ref={input_ref}
|
|
50
|
+
type="checkbox"
|
|
51
|
+
className="peer absolute m-0 h-px w-px overflow-hidden opacity-0"
|
|
52
|
+
checked={checked}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
aria-label={nested && label === undefined ? aria_label : undefined}
|
|
55
|
+
onChange={on_change}
|
|
56
|
+
/>
|
|
57
|
+
<span className={control_class} aria-hidden="true" />
|
|
58
|
+
</>
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if (nested) {
|
|
62
|
+
return <span className={root_class}>{control}</span>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<label className={root_class}>
|
|
67
|
+
{control}
|
|
68
|
+
{label !== undefined ? (
|
|
69
|
+
<span className="select-none text-[0.8rem] leading-tight text-muted">
|
|
70
|
+
{label}
|
|
71
|
+
</span>
|
|
72
|
+
) : null}
|
|
73
|
+
</label>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
|
|
4
|
+
import { get_button_class_name } from '@/lib/get_button_class_name'
|
|
5
|
+
import { get_check_out_confirm_dialog } from '@/lib/get_check_out_confirm_dialog'
|
|
6
|
+
import { prompt_check_out_at } from '@/lib/prompt_check_out_at'
|
|
7
|
+
import { use_confirm_before_checkout } from '@/lib/use_confirm_before_checkout'
|
|
8
|
+
|
|
9
|
+
interface CheckoutButtonGroupProps {
|
|
10
|
+
in_bar?: boolean
|
|
11
|
+
is_pending: boolean
|
|
12
|
+
on_check_out: (at?: string) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const group_button_class = `${get_button_class_name('danger')} rounded-none first:rounded-l-[0.65rem] last:rounded-r-[0.65rem] not-first:-ml-px not-first:min-w-12 not-first:border-l not-first:border-l-[color-mix(in_srgb,var(--danger-text)_30%,var(--background))] max-[860px]:flex-1 max-[860px]:basis-1/2`
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check out now or at a natural-language time in a joined button group.
|
|
19
|
+
*/
|
|
20
|
+
export function CheckoutButtonGroup({
|
|
21
|
+
in_bar = false,
|
|
22
|
+
is_pending,
|
|
23
|
+
on_check_out,
|
|
24
|
+
}: CheckoutButtonGroupProps) {
|
|
25
|
+
const { confirm } = use_confirm_dialog()
|
|
26
|
+
const confirm_before_checkout = use_confirm_before_checkout()
|
|
27
|
+
|
|
28
|
+
const check_out_with_confirm = async (at?: string): Promise<void> => {
|
|
29
|
+
if (confirm_before_checkout) {
|
|
30
|
+
const confirmed = await confirm(get_check_out_confirm_dialog(at))
|
|
31
|
+
|
|
32
|
+
if (!confirmed) {
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
on_check_out(at)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handle_at = (): void => {
|
|
41
|
+
const at = prompt_check_out_at()
|
|
42
|
+
|
|
43
|
+
if (at === null) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
void check_out_with_confirm(at)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
className={`inline-flex shrink-0 ${in_bar ? 'min-w-0 max-[860px]:w-full' : 'min-w-30 max-[860px]:w-full'}`}
|
|
53
|
+
>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
className={group_button_class}
|
|
57
|
+
disabled={is_pending}
|
|
58
|
+
onClick={() => void check_out_with_confirm()}
|
|
59
|
+
>
|
|
60
|
+
Check out
|
|
61
|
+
</button>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className={group_button_class}
|
|
65
|
+
disabled={is_pending}
|
|
66
|
+
title="Check out at a specific time"
|
|
67
|
+
onClick={handle_at}
|
|
68
|
+
>
|
|
69
|
+
@
|
|
70
|
+
</button>
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|