Add Done button, tablet controls, super admin management, midnight strike reset, and activity log improvements

- Done button snaps block to full duration, marks complete, logs "Marked Done by User"; Reset after Done fully un-completes the block
- Session action buttons stretch full-width and double height for tablet tapping
- Super admin: reset password, disable/enable accounts, delete user (with cascade), last active date per user's timezone
- Disabled account login returns specific error message instead of generic invalid credentials
- Users can change own password from Admin → Settings
- Strikes reset automatically at midnight in user's configured timezone (lazy reset on page load)
- Break timer state fully restored when navigating away and back to dashboard
- Timer no longer auto-starts on navigation if it wasn't running before
- Implicit pause guard: no duplicate pause events when switching already-paused blocks or starting a break
- Block selection events removed from activity log; all event types have human-readable labels
- House emoji favicon via inline SVG data URI
- README updated to reflect all changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 00:08:15 -08:00
parent f645d78c83
commit ff9a863393
18 changed files with 768 additions and 108 deletions

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Homeschool Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>" />
</head>
<body>
<div id="app"></div>

View File

@@ -48,20 +48,35 @@ export const useScheduleStore = defineStore('schedule', () => {
// Restore elapsed time from server-computed value and seed the per-block cache
const serverElapsed = snapshot.block_elapsed_seconds || 0
if (snapshot.session?.current_block_id) {
blockElapsedCache.value[snapshot.session.current_block_id] = serverElapsed
blockElapsedOffset.value = serverElapsed
// Start the live counter only when the block is actually running (not paused).
// Use serverElapsed == 0 is fine here — a just-reset block is still running.
blockStartedAt.value = isPaused.value ? null : Date.now()
const blockId = snapshot.session.current_block_id
// If the current block is already completed (Done was pressed), snap elapsed
// to the full block duration so the timer shows 00:00 / Done! on reload.
const isCompleted = completedBlockIds.value.includes(blockId)
const block = blocks.value.find(b => b.id === blockId)
const elapsed = isCompleted && block
? (block.duration_minutes || 0) * 60
: serverElapsed
blockElapsedCache.value[blockId] = elapsed
blockElapsedOffset.value = elapsed
blockStartedAt.value = (isPaused.value || isCompleted) ? null : Date.now()
} else {
blockElapsedOffset.value = 0
blockStartedAt.value = null
}
// Reset break state on snapshot (not persisted across page loads)
isBreakMode.value = false
breakStartedAt.value = null
breakElapsedOffset.value = 0
breakElapsedCache.value = {}
// Restore break state from server
if (snapshot.is_break_active && snapshot.session?.current_block_id) {
const blockId = snapshot.session.current_block_id
const breakElapsed = snapshot.break_elapsed_seconds || 0
isBreakMode.value = true
breakElapsedOffset.value = breakElapsed
breakElapsedCache.value[blockId] = breakElapsed
breakStartedAt.value = snapshot.is_break_paused ? null : Date.now()
} else {
isBreakMode.value = false
breakStartedAt.value = null
breakElapsedOffset.value = 0
breakElapsedCache.value = {}
}
}
function applyWsEvent(event) {
@@ -150,12 +165,15 @@ export const useScheduleStore = defineStore('schedule', () => {
breakStartedAt.value = null
breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0
}
// Reset — clear elapsed to 0 and start counting immediately
// Reset — clear elapsed to 0, stay paused, and un-complete the block
if (event.event === 'reset') {
if (event.block_id) blockElapsedCache.value[event.block_id] = 0
blockElapsedOffset.value = 0
blockStartedAt.value = Date.now()
isPaused.value = false
blockStartedAt.value = null
isPaused.value = true
if (event.uncomplete_block_id) {
completedBlockIds.value = completedBlockIds.value.filter(id => id !== event.uncomplete_block_id)
}
}
// Select — switch current block but keep timer stopped (manual start required)
if (event.event === 'select') {
@@ -337,14 +355,27 @@ export const useScheduleStore = defineStore('schedule', () => {
sendTimerAction(sessionId, 'break_reset', blockId)
}
// Mark the current block as done: snap elapsed to full duration and send complete.
function markBlockDone(sessionId) {
if (!session.value?.current_block_id || !currentBlock.value) return
const blockId = session.value.current_block_id
const fullDuration = (currentBlock.value.duration_minutes || 0) * 60
blockElapsedOffset.value = fullDuration
blockElapsedCache.value[blockId] = fullDuration
blockStartedAt.value = null
isPaused.value = true
sendTimerAction(sessionId, 'complete', blockId)
}
// Reset the current block's timer to 0 and start counting immediately.
function resetCurrentBlock(sessionId) {
if (!session.value?.current_block_id) return
const blockId = session.value.current_block_id
blockElapsedCache.value[blockId] = 0
blockElapsedOffset.value = 0
isPaused.value = false
blockStartedAt.value = Date.now()
isPaused.value = true
blockStartedAt.value = null
completedBlockIds.value = completedBlockIds.value.filter(id => id !== blockId)
sendTimerAction(sessionId, 'reset', blockId)
}
@@ -374,6 +405,7 @@ export const useScheduleStore = defineStore('schedule', () => {
selectBlock,
startCurrentBlock,
resumeCurrentBlock,
markBlockDone,
resetCurrentBlock,
startBreak,
pauseBreak,

View File

@@ -343,10 +343,43 @@
</button>
</div>
</div>
<div class="settings-divider"></div>
<div class="settings-row">
<div class="settings-label">
<div class="settings-title">Password</div>
<div class="settings-hint">Change your account login password.</div>
</div>
<div class="settings-control">
<button class="btn-sm" @click="showPasswordDialog = true">Reset Password</button>
</div>
</div>
</div>
</section>
</main>
</div>
<!-- Change Password Dialog -->
<div class="dialog-overlay" v-if="showPasswordDialog" @click.self="closePasswordDialog">
<div class="dialog">
<h2>Change Password</h2>
<div class="field">
<label>Current Password</label>
<input v-model="currentPassword" type="password" placeholder="Enter current password" />
</div>
<div class="field">
<label>New Password</label>
<input v-model="newPassword" type="password" placeholder="Enter new password" @keyup.enter="submitPasswordChange" />
</div>
<p v-if="passwordError" class="pw-error">{{ passwordError }}</p>
<p v-if="passwordSuccess" class="pw-success">Password updated successfully.</p>
<div class="dialog-actions">
<button class="btn-sm" @click="closePasswordDialog">Cancel</button>
<button class="btn-primary btn-sm" :disabled="savingPassword" @click="submitPasswordChange">
{{ savingPassword ? 'Saving' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
@@ -373,6 +406,45 @@ async function saveTimezone() {
setTimeout(() => { tzSaved.value = false }, 2000)
}
// Settings — Password
const showPasswordDialog = ref(false)
const currentPassword = ref('')
const newPassword = ref('')
const savingPassword = ref(false)
const passwordError = ref('')
const passwordSuccess = ref(false)
function closePasswordDialog() {
showPasswordDialog.value = false
currentPassword.value = ''
newPassword.value = ''
passwordError.value = ''
passwordSuccess.value = false
}
async function submitPasswordChange() {
if (!currentPassword.value || !newPassword.value.trim()) {
passwordError.value = 'Both fields are required'
return
}
savingPassword.value = true
passwordError.value = ''
passwordSuccess.value = false
try {
await api.post('/api/auth/change-password', {
current_password: currentPassword.value,
new_password: newPassword.value,
})
passwordSuccess.value = true
currentPassword.value = ''
newPassword.value = ''
} catch (e) {
passwordError.value = e?.response?.data?.detail || 'Failed to update password'
} finally {
savingPassword.value = false
}
}
// Children
const showChildForm = ref(false)
const newChild = ref({ name: '', color: '#4F46E5' })
@@ -836,6 +908,7 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
.settings-label { flex: 1; min-width: 180px; }
.settings-title { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.2rem; }
.settings-hint { font-size: 0.8rem; color: #64748b; }
.settings-divider { border-top: 1px solid #334155; margin: 1rem 0; }
.settings-control { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
.tz-select {
padding: 0.5rem 0.75rem;
@@ -895,4 +968,40 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
white-space: nowrap;
}
.break-check-label input[type="checkbox"] { cursor: pointer; }
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: #1e293b;
border: 1px solid #334155;
border-radius: 1rem;
padding: 2rem;
width: 360px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 1rem;
}
.dialog h2 { font-size: 1.1rem; font-weight: 700; margin: 0; }
.field { display: flex; flex-direction: column; gap: 0.4rem; }
.field label { font-size: 0.8rem; color: #94a3b8; }
.field input {
padding: 0.6rem 0.85rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 0.5rem;
color: #f1f5f9;
font-size: 0.9rem;
}
.field input:focus { outline: none; border-color: #818cf8; }
.dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
.pw-error { color: #f87171; font-size: 0.85rem; margin: 0; }
.pw-success { color: #4ade80; font-size: 0.85rem; margin: 0; }
</style>

View File

@@ -74,28 +74,31 @@
</div>
<div class="session-actions">
<div class="session-actions-left">
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
@click="sendAction('pause')"
>Pause</button>
<button
class="btn-sm btn-start"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
>Start</button>
<button
class="btn-sm"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
@click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
>Resume</button>
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
>Reset</button>
</div>
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
@click="sendAction('pause')"
>Pause</button>
<button
class="btn-sm btn-start"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
>Start</button>
<button
class="btn-sm"
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
@click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
>Resume</button>
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
>Reset</button>
<button
class="btn-sm"
v-if="scheduleStore.session.current_block_id"
@click="scheduleStore.markBlockDone(scheduleStore.session.id)"
>Done</button>
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
</div>
@@ -419,11 +422,11 @@ h1 { font-size: 1.75rem; font-weight: 700; }
}
.current-block-timer { display: flex; justify-content: center; margin: 1rem 0; }
.session-actions { display: flex; align-items: center; justify-content: space-between; margin-top: 1rem; gap: 0.5rem; }
.session-actions-left { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.session-actions { display: flex; align-items: center; margin-top: 1rem; gap: 0.5rem; }
.session-actions .btn-sm { flex: 1; text-align: center; }
.btn-sm {
padding: 0.4rem 0.9rem;
padding: 0.8rem 0.9rem;
border: 1px solid #334155;
background: transparent;
color: #94a3b8;

View File

@@ -60,6 +60,11 @@
<option value="resume"> Resumed</option>
<option value="complete"> Completed</option>
<option value="skip"> Skipped</option>
<option value="reset"> Reset</option>
<option value="break_start"> Break started</option>
<option value="break_pause"> Break paused</option>
<option value="break_resume"> Break resumed</option>
<option value="break_reset"> Break reset</option>
</select>
</div>
<div class="edit-field">
@@ -199,6 +204,11 @@ const EVENT_META = {
resume: { icon: '↺', label: 'Resumed' },
complete: { icon: '✓', label: 'Completed' },
skip: { icon: '⟶', label: 'Skipped' },
reset: { icon: '↩', label: 'Reset' },
break_start: { icon: '☕', label: 'Break started' },
break_pause: { icon: '⏸', label: 'Break paused' },
break_resume: { icon: '↺', label: 'Break resumed' },
break_reset: { icon: '↩', label: 'Break reset' },
}
function eventIcon(entry) {
@@ -217,6 +227,7 @@ function eventLabel(entry) {
: `Strike removed (${entry.new_strikes}/3)`
}
if (entry.event_type === 'complete' && !entry.block_label) return 'Day ended'
if (entry.event_type === 'complete' && entry.block_label) return `Marked Done by User — ${entry.block_label}`
const action = EVENT_META[entry.event_type]?.label || entry.event_type
return entry.block_label ? `${action}${entry.block_label}` : action
}

View File

@@ -19,15 +19,69 @@
{{ u.is_active ? 'Active' : 'Inactive' }}
</span>
<span class="joined">Joined {{ formatDate(u.created_at) }}</span>
<span class="last-active">
{{ u.last_active_at ? 'Last active ' + formatDate(u.last_active_at, u.timezone) : 'Never logged in' }}
</span>
</div>
</div>
<button class="enter-btn" :disabled="entering === u.id" @click="enter(u.id)">
{{ entering === u.id ? 'Entering' : 'Enter as User' }}
</button>
<div class="card-actions">
<button class="enter-btn" :disabled="entering === u.id || !u.is_active" @click="enter(u.id)">
{{ entering === u.id ? 'Entering' : 'Enter as User' }}
</button>
<button class="reset-btn" @click="openReset(u)">Reset Password</button>
<button
class="toggle-btn"
:class="u.is_active ? 'toggle-disable' : 'toggle-enable'"
:disabled="toggling === u.id"
@click="toggleActive(u)"
>{{ toggling === u.id ? '…' : u.is_active ? 'Disable' : 'Enable' }}</button>
<button class="delete-btn" @click="openDelete(u)">Delete</button>
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div class="dialog-overlay" v-if="deleteTarget" @click.self="deleteTarget = null">
<div class="dialog">
<h2>Delete User</h2>
<p class="dialog-user">{{ deleteTarget.full_name || deleteTarget.email }}</p>
<p class="delete-warning">This will permanently delete the user and all associated data children, schedules, sessions, activity logs, and subjects. This cannot be undone.</p>
<p v-if="deleteError" class="reset-error">{{ deleteError }}</p>
<div class="dialog-actions">
<button class="cancel-btn" @click="deleteTarget = null">Cancel</button>
<button class="delete-confirm-btn" :disabled="deleting" @click="submitDelete">
{{ deleting ? 'Deleting' : 'Delete Permanently' }}
</button>
</div>
</div>
</div>
<!-- Reset Password Dialog -->
<div class="dialog-overlay" v-if="resetTarget" @click.self="closeReset">
<div class="dialog">
<h2>Reset Password</h2>
<p class="dialog-user">{{ resetTarget.full_name || resetTarget.email }}</p>
<div class="field">
<label>New Password</label>
<input
v-model="newPassword"
type="password"
placeholder="Enter new password"
@keyup.enter="submitReset"
/>
</div>
<p v-if="resetError" class="reset-error">{{ resetError }}</p>
<p v-if="resetSuccess" class="reset-success">Password updated successfully.</p>
<div class="dialog-actions">
<button class="cancel-btn" @click="closeReset">Cancel</button>
<button class="confirm-btn" :disabled="resetting" @click="submitReset">
{{ resetting ? 'Saving' : 'Save' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
@@ -43,6 +97,82 @@ const users = ref([])
const loading = ref(true)
const error = ref('')
const entering = ref(null)
const toggling = ref(null)
async function toggleActive(user) {
toggling.value = user.id
try {
const res = await axios.post(`/api/admin/toggle-active/${user.id}`, {}, {
headers: { Authorization: `Bearer ${superAdmin.adminToken}` },
})
user.is_active = res.data.is_active
} catch {
error.value = 'Failed to update user status'
} finally {
toggling.value = null
}
}
const deleteTarget = ref(null)
const deleting = ref(false)
const deleteError = ref('')
function openDelete(user) {
deleteTarget.value = user
deleteError.value = ''
}
async function submitDelete() {
deleting.value = true
deleteError.value = ''
try {
await axios.delete(`/api/admin/users/${deleteTarget.value.id}`, {
headers: { Authorization: `Bearer ${superAdmin.adminToken}` },
})
users.value = users.value.filter(u => u.id !== deleteTarget.value.id)
deleteTarget.value = null
} catch {
deleteError.value = 'Failed to delete user'
} finally {
deleting.value = false
}
}
const resetTarget = ref(null)
const newPassword = ref('')
const resetting = ref(false)
const resetError = ref('')
const resetSuccess = ref(false)
function openReset(user) {
resetTarget.value = user
newPassword.value = ''
resetError.value = ''
resetSuccess.value = false
}
function closeReset() {
resetTarget.value = null
}
async function submitReset() {
if (!newPassword.value.trim()) { resetError.value = 'Password cannot be empty'; return }
resetting.value = true
resetError.value = ''
resetSuccess.value = false
try {
await axios.post(`/api/admin/reset-password/${resetTarget.value.id}`,
{ new_password: newPassword.value },
{ headers: { Authorization: `Bearer ${superAdmin.adminToken}` } }
)
resetSuccess.value = true
newPassword.value = ''
} catch {
resetError.value = 'Failed to reset password'
} finally {
resetting.value = false
}
}
onMounted(async () => {
try {
@@ -73,9 +203,11 @@ function handleLogout() {
router.push('/super-admin/login')
}
function formatDate(iso) {
function formatDate(iso, timezone) {
if (!iso) return '—'
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
const opts = { year: 'numeric', month: 'short', day: 'numeric' }
if (timezone) opts.timeZone = timezone
return new Date(iso).toLocaleDateString(undefined, opts)
}
</script>
@@ -192,6 +324,49 @@ h2 {
font-size: 0.8rem;
}
.last-active {
color: #64748b;
font-size: 0.8rem;
}
.card-actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.reset-btn {
padding: 0.45rem 1rem;
background: transparent;
border: 1px solid #334155;
color: #94a3b8;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
white-space: nowrap;
}
.reset-btn:hover {
border-color: #818cf8;
color: #818cf8;
}
.toggle-btn {
padding: 0.45rem 1rem;
background: transparent;
border: 1px solid #334155;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
white-space: nowrap;
font-weight: 600;
}
.toggle-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.toggle-disable { border-color: #7f1d1d; color: #fca5a5; }
.toggle-disable:hover:not(:disabled) { background: #7f1d1d; }
.toggle-enable { border-color: #14532d; color: #4ade80; }
.toggle-enable:hover:not(:disabled) { background: #14532d; }
.enter-btn {
padding: 0.45rem 1rem;
background: #f59e0b;
@@ -208,4 +383,131 @@ h2 {
opacity: 0.6;
cursor: not-allowed;
}
.delete-btn {
padding: 0.45rem 1rem;
background: transparent;
border: 1px solid #7f1d1d;
color: #fca5a5;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
white-space: nowrap;
font-weight: 600;
}
.delete-btn:hover { background: #7f1d1d; }
.delete-warning {
font-size: 0.875rem;
color: #fca5a5;
background: #450a0a;
border: 1px solid #7f1d1d;
border-radius: 6px;
padding: 0.75rem 1rem;
margin: 0;
line-height: 1.5;
}
.delete-confirm-btn {
padding: 0.45rem 1rem;
background: #7f1d1d;
color: #fca5a5;
font-weight: 600;
border: 1px solid #991b1b;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.delete-confirm-btn:hover:not(:disabled) { background: #991b1b; }
.delete-confirm-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
padding: 2rem;
width: 360px;
max-width: 90vw;
}
.dialog h2 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.dialog-user {
color: #64748b;
font-size: 0.875rem;
margin-bottom: 1.25rem;
}
.field label {
display: block;
font-size: 0.8rem;
color: #94a3b8;
margin-bottom: 0.4rem;
}
.field input {
width: 100%;
padding: 0.6rem 0.85rem;
background: #0f172a;
border: 1px solid #334155;
border-radius: 6px;
color: #f1f5f9;
font-size: 0.9rem;
box-sizing: border-box;
}
.field input:focus {
outline: none;
border-color: #818cf8;
}
.reset-error { color: #f87171; font-size: 0.85rem; margin-top: 0.75rem; }
.reset-success { color: #4ade80; font-size: 0.85rem; margin-top: 0.75rem; }
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.cancel-btn {
padding: 0.45rem 1rem;
background: transparent;
border: 1px solid #334155;
color: #94a3b8;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.cancel-btn:hover { border-color: #94a3b8; }
.confirm-btn {
padding: 0.45rem 1rem;
background: #4f46e5;
color: #fff;
font-weight: 600;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.confirm-btn:hover { background: #4338ca; }
.confirm-btn:disabled { opacity: 0.6; cursor: not-allowed; }
</style>