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:
112
README.md
112
README.md
@@ -8,23 +8,31 @@ A self-hosted web app for managing homeschool schedules, tracking daily learning
|
|||||||
|
|
||||||
- **TV Dashboard** — Full-screen display for the living room TV. Shows the current subject, countdown timer, day progress bar, activity options, and the schedule block list. Updates live without page refresh via WebSocket.
|
- **TV Dashboard** — Full-screen display for the living room TV. Shows the current subject, countdown timer, day progress bar, activity options, and the schedule block list. Updates live without page refresh via WebSocket.
|
||||||
- **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins.
|
- **Morning Routine** — Define a list of morning routine items in Admin. They appear in the TV dashboard Activities panel during the "Good Morning" greeting before the first block starts, then switch to subject-specific activities once a block begins.
|
||||||
- **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options.
|
- **Break Time** — Each schedule block can optionally include a break at the end. Enable the checkbox and set a duration (in minutes) when building a block in Admin. Once the block's main timer is done, a **Break Time** section appears on the Dashboard with its own **Start / Pause / Resume / Reset** controls — the break does not start automatically. While break is active the TV left column switches to an amber break badge and countdown timer, and the center column shows the configurable **Break Activities** list instead of subject options. Break timer state is fully restored when navigating away and returning to the dashboard.
|
||||||
- **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break.
|
- **Break Activities** — A global list of break-time activities (e.g. "Get a snack", "Go outside") managed in Admin → Break Activities, using the same add/edit/delete interface as Morning Routine. These items are shown on the TV during any active break.
|
||||||
- **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **🟢 Start** and **Finish 🏁**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks.
|
- **Day Progress Bar** — Both the TV dashboard and the parent dashboard display a progress bar showing how far through the day the child is. Progress is calculated from total scheduled block time vs. remaining block time — not wall-clock time — so it advances only as blocks are actively worked. On the TV the bar is labeled **Start** and **Finish**. On the parent dashboard the left label shows the scheduled start time of the first block and the right label shows a live-updating **estimated finish time** computed as the current time plus all remaining block time and break time for incomplete blocks.
|
||||||
- **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page.
|
- **Schedule Builder** — Create named schedule templates with time blocks assigned to subjects. Each block supports an optional custom duration override, label, and break time setting. Managed inside the Admin page.
|
||||||
- **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, and **Reset** to clear the elapsed time and restart the timer from zero. Elapsed time per block is remembered across switches, so returning to a block picks up where it left off.
|
- **Daily Sessions** — Start a school day against a schedule template. Click any block in the list to select it as the current block. Use the **Start** button to begin timing, **Pause** to stop, **Resume** to continue from where you left off, **Done** to mark it as fully complete, and **Reset** to clear the elapsed time back to zero (timer stays paused). Elapsed time per block is remembered across switches, so returning to a block picks up where it left off.
|
||||||
|
- **Done Button** — Marks the current block as fully elapsed (timer snaps to 00:00 / "Done!"), adds a checkmark in the schedule list, and logs "Marked Done by User" in the activity log. Pressing Reset after Done clears the completed state entirely — removing the checkmark and the Done label — treating the block as if it were never started.
|
||||||
- **Block Timer Remaining** — Each block in the schedule list shows time remaining (allocated duration minus elapsed), counting down live on both the parent dashboard and the TV sidebar. Shows "< 1 min" when under a minute, and "Done!" when the full duration is elapsed.
|
- **Block Timer Remaining** — Each block in the schedule list shows time remaining (allocated duration minus elapsed), counting down live on both the parent dashboard and the TV sidebar. Shows "< 1 min" when under a minute, and "Done!" when the full duration is elapsed.
|
||||||
- **Activity Log** — Automatically records every timer event (day started, block start/pause/resume/complete/skip/reset, break start/pause/resume/reset) and every strike change as a timestamped timeline. Includes which schedule template was used. Supports manual notes with free text. Browse and filter history by child and date.
|
- **Tablet-Friendly Controls** — The session control buttons (Start/Pause/Resume, Reset, Done, End Day) stretch full-width across the session card and are taller for easy tapping on a tablet.
|
||||||
- **Behavior Tracking (Strikes)** — Issue up to 3 strikes per child from the Dashboard. Strike additions and removals are logged in the activity log with a timestamp. Strike count is shown on the TV dashboard and resets automatically when a new school day begins.
|
- **Activity Log** — Automatically records every timer event (day started, block start/pause/resume/complete/reset, break start/pause/resume/reset) as a timestamped timeline. Block selection events are intentionally not logged. The "Done" button logs as "Marked Done by User — Block Name". All event types display with a human-readable label and icon. Includes which schedule template was used. Supports manual notes with free text. Browse and filter history by child and date.
|
||||||
- **Timezone Support** — Set your local timezone in Admin → Settings. All activity log timestamps display in your timezone, including the TV dashboard clock.
|
- **Behavior Tracking (Strikes)** — Issue up to 3 strikes per child from the Dashboard. Strike additions and removals are logged in the activity log with a timestamp. Strike count is shown on the TV dashboard. Strikes **reset automatically at midnight** in the user's configured timezone — the reset happens lazily on the first page load after midnight, so no scheduled job is required.
|
||||||
|
- **Timezone Support** — Set your local timezone in Admin → Settings. All activity log timestamps display in your timezone, including the TV dashboard clock. Midnight strike resets use this timezone.
|
||||||
|
- **Password Change** — Users can change their own account password from Admin → Settings → Reset Password. The form requires the current password before accepting a new one.
|
||||||
- **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history.
|
- **Multi-Child Support** — Manage multiple students under one parent account, each with their own color, schedule, and history.
|
||||||
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required).
|
- **JWT Authentication** — Secure parent login with access tokens and httpOnly refresh cookies. TV dashboard is public (no login required). Disabled accounts receive a clear error message explaining the account is disabled rather than a generic "invalid credentials" response.
|
||||||
- **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Lists all registered parent accounts and allows impersonating any user — switching into their session to view and manage their data. An impersonation banner is shown at the top of the screen with a one-click "Exit to Admin Panel" button.
|
- **Super Admin Panel** — A separate admin interface (at `/super-admin`) for site-wide management. Log in with a dedicated admin username and password (set in `.env`). Provides full control over all registered parent accounts:
|
||||||
- **Meeting Subject** — A system subject called "Meeting" (📅) is automatically created for every user and cannot be deleted or renamed. Add it to any schedule block like a normal subject and assign activity options (e.g. agenda items) that will display on the TV during the meeting.
|
- **Impersonate** — Enter any user's session to view and manage their data. An impersonation banner is shown at the top of every page with a one-click "Exit to Admin Panel" button.
|
||||||
|
- **Reset Password** — Set a new password for any user without needing the current password.
|
||||||
|
- **Disable / Enable** — Disable a user's login access. Disabled users cannot log in and see a specific error message. Re-enable at any time.
|
||||||
|
- **Delete** — Permanently delete a user and all associated data (children, sessions, schedules, subjects, activity logs, etc.) with a confirmation dialog. This action cannot be undone.
|
||||||
|
- **Last Active** — Shows the date each user last logged in or refreshed their session, displayed in that user's configured timezone. Shows "Never logged in" for accounts that have never authenticated.
|
||||||
|
- **Meeting Subject** — A system subject called "Meeting" (calendar icon) is automatically created for every user and cannot be deleted or renamed. Add it to any schedule block like a normal subject and assign activity options (e.g. agenda items) that will display on the TV during the meeting.
|
||||||
- **Meeting Notifications** — When a schedule block assigned to the Meeting subject is approaching, the app automatically alerts everyone:
|
- **Meeting Notifications** — When a schedule block assigned to the Meeting subject is approaching, the app automatically alerts everyone:
|
||||||
- **5-minute warning** — An amber corner toast appears on both the parent Dashboard and the TV with the meeting name and a live countdown. Tap ✕ to dismiss.
|
- **5-minute warning** — An amber corner toast appears on both the parent Dashboard and the TV with the meeting name and a live countdown. Tap X to dismiss.
|
||||||
- **1-minute re-notify** — If the 5-minute toast was dismissed, it reappears at the 1-minute mark.
|
- **1-minute re-notify** — If the 5-minute toast was dismissed, it reappears at the 1-minute mark.
|
||||||
- **At start time** — A full-screen overlay fires on the TV with the meeting name and a 30-second auto-dismiss countdown (tap anywhere to dismiss early). Simultaneously, the currently running block timer is paused, the schedule switches to the Meeting block, and its timer starts automatically. The TV center panel switches to show the meeting's activity options.
|
- **At start time** — A full-screen overlay fires on the TV with the meeting name and a 30-second auto-dismiss countdown (tap anywhere to dismiss early). Simultaneously, the schedule switches to the Meeting block and its timer starts automatically.
|
||||||
- **Chime sounds** — A rising three-note chime plays on warnings; a falling chime plays at meeting start. Generated via the Web Audio API — no audio files required.
|
- **Chime sounds** — A rising three-note chime plays on warnings; a falling chime plays at meeting start. Generated via the Web Audio API — no audio files required.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -55,32 +63,34 @@ homeschool/
|
|||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── requirements.txt
|
│ ├── requirements.txt
|
||||||
│ └── app/
|
│ └── app/
|
||||||
│ ├── main.py # FastAPI app entry point + table auto-creation
|
│ ├── main.py # FastAPI app entry point + table auto-creation + migrations
|
||||||
│ ├── config.py # Settings (reads from .env)
|
│ ├── config.py # Settings (reads from .env)
|
||||||
│ ├── database.py # Async SQLAlchemy engine
|
│ ├── database.py # Async SQLAlchemy engine
|
||||||
│ ├── dependencies.py # Auth dependencies (get_current_user)
|
│ ├── dependencies.py # Auth dependencies (get_current_user, get_admin_user)
|
||||||
│ ├── auth/jwt.py # Token creation, password hashing
|
│ ├── auth/jwt.py # Token creation, password hashing
|
||||||
│ ├── models/ # SQLAlchemy ORM models
|
│ ├── models/ # SQLAlchemy ORM models
|
||||||
│ │ ├── child.py
|
│ │ ├── child.py # Child (incl. strikes, strikes_last_reset)
|
||||||
│ │ ├── subject.py # Subject + SubjectOption
|
│ │ ├── subject.py # Subject + SubjectOption
|
||||||
│ │ ├── schedule.py # ScheduleTemplate + ScheduleBlock (incl. break fields)
|
│ │ ├── schedule.py # ScheduleTemplate + ScheduleBlock (incl. break fields)
|
||||||
│ │ ├── session.py # DailySession + TimerEvent
|
│ │ ├── session.py # DailySession + TimerEvent
|
||||||
│ │ ├── activity.py # ActivityLog (manual notes)
|
│ │ ├── activity.py # ActivityLog (manual notes)
|
||||||
│ │ ├── morning_routine.py# MorningRoutineItem
|
│ │ ├── morning_routine.py# MorningRoutineItem
|
||||||
│ │ ├── break_activity.py # BreakActivityItem
|
│ │ ├── break_activity.py # BreakActivityItem
|
||||||
│ │ └── strike.py # StrikeEvent (strike history)
|
│ │ ├── strike.py # StrikeEvent (strike history)
|
||||||
|
│ │ └── user.py # User (incl. timezone, last_active_at)
|
||||||
│ ├── schemas/ # Pydantic request/response schemas
|
│ ├── schemas/ # Pydantic request/response schemas
|
||||||
│ ├── routers/ # API route handlers
|
│ ├── routers/ # API route handlers
|
||||||
│ │ ├── auth.py
|
│ │ ├── auth.py # Login, register, refresh, logout, change-password
|
||||||
│ │ ├── children.py
|
│ │ ├── children.py # Children CRUD + strikes + midnight reset
|
||||||
│ │ ├── subjects.py
|
│ │ ├── subjects.py
|
||||||
│ │ ├── schedules.py
|
│ │ ├── schedules.py
|
||||||
│ │ ├── sessions.py # Timer actions + break timer events
|
│ │ ├── sessions.py # Timer actions + break timer events
|
||||||
│ │ ├── logs.py # Timeline + strike events
|
│ │ ├── logs.py # Timeline + strike events
|
||||||
│ │ ├── morning_routine.py
|
│ │ ├── morning_routine.py
|
||||||
│ │ ├── break_activity.py # Break activities CRUD
|
│ │ ├── break_activity.py # Break activities CRUD
|
||||||
│ │ ├── dashboard.py # Public snapshot endpoint (TV)
|
│ │ ├── dashboard.py # Public snapshot endpoint (TV + dashboard reload)
|
||||||
│ │ ├── admin.py # Super admin: login, user list, impersonation
|
│ │ ├── admin.py # Super admin: login, user list, impersonate, reset-password,
|
||||||
|
│ │ │ # toggle-active, delete-user
|
||||||
│ │ └── users.py
|
│ │ └── users.py
|
||||||
│ ├── utils/
|
│ ├── utils/
|
||||||
│ │ └── timer.py # Elapsed-time computation for block and break timers
|
│ │ └── timer.py # Elapsed-time computation for block and break timers
|
||||||
@@ -89,6 +99,7 @@ homeschool/
|
|||||||
└── frontend/
|
└── frontend/
|
||||||
├── Dockerfile # Multi-stage: Node build → nginx serve
|
├── Dockerfile # Multi-stage: Node build → nginx serve
|
||||||
├── nginx.conf # Proxy /api/ and /ws/ to backend
|
├── nginx.conf # Proxy /api/ and /ws/ to backend
|
||||||
|
├── index.html # Favicon set to house emoji via inline SVG
|
||||||
└── src/
|
└── src/
|
||||||
├── composables/
|
├── composables/
|
||||||
│ ├── useApi.js # Axios with auto token-refresh
|
│ ├── useApi.js # Axios with auto token-refresh
|
||||||
@@ -112,7 +123,7 @@ homeschool/
|
|||||||
### 1. Clone the repo
|
### 1. Clone the repo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://git.chns.tech/CooperandGoodman/homeschool.git
|
git clone <your-repo-url>
|
||||||
cd homeschool
|
cd homeschool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -163,10 +174,10 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
|
|||||||
2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block. The **Meeting** subject is created automatically and cannot be deleted or renamed, but you can add activity options (agenda items) to it.
|
2. **Admin** → Add subjects (Math, Reading, Science, etc.) with emoji icons and colors. Add activity options to each subject — they appear on the TV dashboard during that block. The **Meeting** subject is created automatically and cannot be deleted or renamed, but you can add activity options (agenda items) to it.
|
||||||
3. **Admin** → Add **Morning Routine** items — these show on the TV during the greeting before the first block starts.
|
3. **Admin** → Add **Morning Routine** items — these show on the TV during the greeting before the first block starts.
|
||||||
4. **Admin** → Add **Break Activities** items — these show on the TV center panel whenever a break is active.
|
4. **Admin** → Add **Break Activities** items — these show on the TV center panel whenever a break is active.
|
||||||
5. **Admin** → Scroll to **Settings** and select your local timezone
|
5. **Admin** → Scroll to **Settings** and select your local timezone. You can also change your account password here.
|
||||||
6. **Admin** → Scroll to **Schedules** → Create a schedule template, set school day hours, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes.
|
6. **Admin** → Scroll to **Schedules** → Create a schedule template, add time blocks assigned to subjects. For any block that should include a break, check **Break** and enter the break duration in minutes.
|
||||||
6. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
|
7. **Dashboard** (`/dashboard`) → Click "Start Day", choose a template
|
||||||
7. **TV** → Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID)
|
8. **TV** → Open `http://your-lan-ip:8054/tv/1` on the living room TV (replace `1` with the child's ID)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -178,20 +189,22 @@ Open **http://localhost:8054/login** and register. This creates your admin accou
|
|||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes |
|
| `/dashboard` | Overview, start/stop sessions, select and time blocks, issue behavior strikes |
|
||||||
| `/logs` | Browse timer and strike event history and manual notes; filter by child and date |
|
| `/logs` | Browse timer and strike event history and manual notes; filter by child and date |
|
||||||
| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, schedule templates, and account settings |
|
| `/admin` | Manage children, subjects (with activity options), morning routine, break activities, schedule templates, and account settings (timezone, password) |
|
||||||
|
|
||||||
### Super Admin Views
|
### Super Admin Views
|
||||||
|
|
||||||
| URL | Description |
|
| URL | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `/super-admin/login` | Log in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` |
|
| `/super-admin/login` | Log in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` |
|
||||||
| `/super-admin` | List all registered parent accounts and impersonate any user |
|
| `/super-admin` | Manage all registered parent accounts |
|
||||||
|
|
||||||
|
Super admin actions per user: **Enter as User** (impersonate), **Reset Password**, **Disable / Enable** login access, **Delete** account and all data. Each user card also shows the account status badge, join date, and last active date (in the user's own timezone).
|
||||||
|
|
||||||
While impersonating, a yellow banner appears at the top of every page showing who you're viewing as, with an **Exit to Admin Panel** button to return.
|
While impersonating, a yellow banner appears at the top of every page showing who you're viewing as, with an **Exit to Admin Panel** button to return.
|
||||||
|
|
||||||
### Dashboard Controls
|
### Dashboard Controls
|
||||||
|
|
||||||
While a session is active, clicking a block in the schedule list **selects** it as the current block without starting the timer. The action buttons then provide explicit control:
|
While a session is active, clicking a block in the schedule list **selects** it as the current block without starting the timer. The action buttons provide explicit control and span the full width of the card for easy tapping on a tablet:
|
||||||
|
|
||||||
**Main block timer:**
|
**Main block timer:**
|
||||||
|
|
||||||
@@ -200,9 +213,12 @@ While a session is active, clicking a block in the schedule list **selects** it
|
|||||||
| **Start** | Block selected, never timed | Begin counting from zero |
|
| **Start** | Block selected, never timed | Begin counting from zero |
|
||||||
| **Resume** | Block was previously paused | Continue from saved elapsed time |
|
| **Resume** | Block was previously paused | Continue from saved elapsed time |
|
||||||
| **Pause** | Timer is running | Stop counting, save elapsed time |
|
| **Pause** | Timer is running | Stop counting, save elapsed time |
|
||||||
| **Reset** | Any current block | Clear elapsed to zero and restart timer immediately |
|
| **Reset** | Any current block | Clear elapsed to zero; timer stays paused |
|
||||||
|
| **Done** | Any current block | Snap timer to full duration (00:00 / "Done!"), mark block complete; logs "Marked Done by User" |
|
||||||
| **End Day** | Session active | Mark the session complete |
|
| **End Day** | Session active | Mark the session complete |
|
||||||
|
|
||||||
|
Pressing **Reset** after **Done** fully un-marks the block — removes the checkmark, clears the completed state in the database, and treats the block as if it were never started.
|
||||||
|
|
||||||
**Break timer** (shown when the current block has Break Time enabled):
|
**Break timer** (shown when the current block has Break Time enabled):
|
||||||
|
|
||||||
| Button | Condition | Action |
|
| Button | Condition | Action |
|
||||||
@@ -216,7 +232,7 @@ While a session is active, clicking a block in the schedule list **selects** it
|
|||||||
|
|
||||||
| URL | Description |
|
| URL | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar (🟢 Start → Finish 🏁), schedule sidebar, meeting warning toasts, meeting start overlay |
|
| `/tv/:childId` | Full-screen display — greeting + morning routine, current block timer with subject activities, break timer with break activities, day progress bar, schedule sidebar, meeting warning toasts, meeting start overlay |
|
||||||
|
|
||||||
Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
|
Point a browser on the living room TV at `http://your-lan-ip:8054/tv/1`. The page connects via WebSocket and updates automatically when a parent starts/stops/advances the timer from the Dashboard.
|
||||||
|
|
||||||
@@ -252,20 +268,42 @@ The TV dashboard connects to `ws://host/ws/{child_id}` and receives JSON events:
|
|||||||
| `start` | Block timer started | `block_id`, `current_block_id`, `block_elapsed_seconds`, `prev_block_id`, `prev_block_elapsed_seconds` |
|
| `start` | Block timer started | `block_id`, `current_block_id`, `block_elapsed_seconds`, `prev_block_id`, `prev_block_elapsed_seconds` |
|
||||||
| `pause` | Block timer paused | `block_id`, `current_block_id` |
|
| `pause` | Block timer paused | `block_id`, `current_block_id` |
|
||||||
| `resume` | Block timer resumed | `block_id`, `current_block_id` |
|
| `resume` | Block timer resumed | `block_id`, `current_block_id` |
|
||||||
| `select` | Block selected (not started) | `block_id`, `current_block_id`, `block_elapsed_seconds`, `prev_block_id`, `prev_block_elapsed_seconds` |
|
| `reset` | Block timer reset to zero | `block_id`, `current_block_id`, `block_elapsed_seconds` (always 0), `uncomplete_block_id` |
|
||||||
| `reset` | Block timer reset to zero | `block_id`, `current_block_id`, `block_elapsed_seconds` (always 0) |
|
| `complete` | Block marked done / session ended | `block_id` (if block done), `is_active: false` (if session ended) |
|
||||||
| `break_start` | Break timer started | `block_id`, `current_block_id`, `break_elapsed_seconds` |
|
| `break_start` | Break timer started | `block_id`, `current_block_id`, `break_elapsed_seconds`, `block_elapsed_seconds` |
|
||||||
| `break_pause` | Break timer paused | `block_id`, `current_block_id` |
|
| `break_pause` | Break timer paused | `block_id`, `current_block_id` |
|
||||||
| `break_resume` | Break timer resumed | `block_id`, `current_block_id` |
|
| `break_resume` | Break timer resumed | `block_id`, `current_block_id` |
|
||||||
| `break_reset` | Break timer reset to zero | `block_id`, `current_block_id`, `break_elapsed_seconds` (always 0) |
|
| `break_reset` | Break timer reset to zero | `block_id`, `current_block_id`, `break_elapsed_seconds` (always 0) |
|
||||||
| `complete` | Session ended | `is_active: false` |
|
| `strikes_update` | Strike issued/cleared/midnight reset | `strikes` |
|
||||||
| `strikes_update` | Strike issued/cleared | `strikes` |
|
|
||||||
|
|
||||||
`block_elapsed_seconds` on `start` and `select` events carries the authoritative accumulated elapsed time for that block (all previous intervals, respecting any prior resets), so every client — including the TV — can restore the correct timer offset without a local cache.
|
**Notes:**
|
||||||
|
|
||||||
`prev_block_id` and `prev_block_elapsed_seconds` on `start` and `select` events carry the saved elapsed for the block being left, so the TV sidebar immediately shows the correct remaining time for that block.
|
- `block_elapsed_seconds` on `start` carries the authoritative accumulated elapsed time so every client can restore the correct timer offset without a local cache.
|
||||||
|
- `prev_block_id` and `prev_block_elapsed_seconds` on `start` carry the saved elapsed for the block being left, so the TV sidebar immediately shows the correct remaining time for that block.
|
||||||
|
- `uncomplete_block_id` on `reset` tells clients to remove that block from the completed list (used when Reset is pressed after Done).
|
||||||
|
- `select` events are broadcast via WebSocket but are **not** persisted to the database or shown in the activity log.
|
||||||
|
- Implicit `pause` events (written when switching blocks or starting a break) are only recorded if the block's timer was actually running — no duplicate pauses are written if the block was already paused or never started.
|
||||||
|
- Break timer events (`break_*`) do not affect block selection or elapsed time for the main block timer.
|
||||||
|
|
||||||
Break timer events (`break_*`) are stored as `TimerEvent` records alongside regular timer events but are computed and broadcast independently — they do not affect block selection, implicit pauses, or elapsed time for the main block timer.
|
---
|
||||||
|
|
||||||
|
## Activity Log Event Types
|
||||||
|
|
||||||
|
| Event | Display label | When recorded |
|
||||||
|
|-------|--------------|---------------|
|
||||||
|
| `session_start` | Day started | New session begins |
|
||||||
|
| `start` | Started | Block timer starts |
|
||||||
|
| `pause` | Paused | Block timer stops (manual or implicit on block switch) |
|
||||||
|
| `resume` | Resumed | Block timer continues after a pause |
|
||||||
|
| `reset` | Reset | Block timer cleared to zero |
|
||||||
|
| `complete` (no block) | Day ended | Session marked complete |
|
||||||
|
| `complete` (with block) | Marked Done by User | Done button pressed on a block |
|
||||||
|
| `break_start` | Break started | Break timer starts |
|
||||||
|
| `break_pause` | Break paused | Break timer stops |
|
||||||
|
| `break_resume` | Break resumed | Break timer continues |
|
||||||
|
| `break_reset` | Break reset | Break timer cleared |
|
||||||
|
|
||||||
|
`select` events are not recorded in the activity log.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ async def lifespan(app: FastAPI):
|
|||||||
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
|
await _add_column_if_missing(conn, "children", "strikes", "INT NOT NULL DEFAULT 0")
|
||||||
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
|
await _add_column_if_missing(conn, "users", "timezone", "VARCHAR(64) NOT NULL DEFAULT 'UTC'")
|
||||||
await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0")
|
await _add_column_if_missing(conn, "subjects", "is_system", "TINYINT(1) NOT NULL DEFAULT 0")
|
||||||
|
await _add_column_if_missing(conn, "users", "last_active_at", "DATETIME NULL")
|
||||||
|
await _add_column_if_missing(conn, "children", "strikes_last_reset", "DATE NULL")
|
||||||
|
|
||||||
# Seed Meeting system subject for any existing users who don't have one
|
# Seed Meeting system subject for any existing users who don't have one
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from sqlalchemy import String, Boolean, ForeignKey, Date, Integer
|
from sqlalchemy import String, Boolean, ForeignKey, Date, Integer
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from typing import Optional
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ class Child(TimestampMixin, Base):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
|
color: Mapped[str] = mapped_column(String(7), default="#4F46E5") # hex color for UI
|
||||||
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
strikes: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
strikes_last_reset: Mapped[Optional[date]] = mapped_column(Date, nullable=True, default=None)
|
||||||
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
|
user: Mapped["User"] = relationship("User", back_populates="children") # noqa: F821
|
||||||
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
|
daily_sessions: Mapped[list["DailySession"]] = relationship( # noqa: F821
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from sqlalchemy import String, Boolean
|
from datetime import datetime
|
||||||
|
from sqlalchemy import String, Boolean, DateTime
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ class User(TimestampMixin, Base):
|
|||||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
|
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="UTC")
|
||||||
|
last_active_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||||
|
|
||||||
children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
|
children: Mapped[list["Child"]] = relationship("Child", back_populates="user") # noqa: F821
|
||||||
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821
|
subjects: Mapped[list["Subject"]] = relationship("Subject", back_populates="user") # noqa: F821
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, delete
|
||||||
|
|
||||||
from app.auth.jwt import create_admin_token, create_access_token
|
from app.auth.jwt import create_admin_token, create_access_token, hash_password
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.dependencies import get_db, get_admin_user
|
from app.dependencies import get_db, get_admin_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -35,11 +35,62 @@ async def list_users(
|
|||||||
"full_name": u.full_name,
|
"full_name": u.full_name,
|
||||||
"is_active": u.is_active,
|
"is_active": u.is_active,
|
||||||
"created_at": u.created_at,
|
"created_at": u.created_at,
|
||||||
|
"last_active_at": u.last_active_at,
|
||||||
|
"timezone": u.timezone,
|
||||||
}
|
}
|
||||||
for u in users
|
for u in users
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/toggle-active/{user_id}")
|
||||||
|
async def toggle_user_active(
|
||||||
|
user_id: int,
|
||||||
|
_: dict = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
user.is_active = not user.is_active
|
||||||
|
await db.commit()
|
||||||
|
return {"id": user.id, "is_active": user.is_active}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/users/{user_id}")
|
||||||
|
async def delete_user(
|
||||||
|
user_id: int,
|
||||||
|
_: dict = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
await db.execute(delete(User).where(User.id == user_id))
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password/{user_id}")
|
||||||
|
async def reset_user_password(
|
||||||
|
user_id: int,
|
||||||
|
body: dict,
|
||||||
|
_: dict = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
new_password = body.get("new_password", "").strip()
|
||||||
|
if not new_password:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="new_password is required")
|
||||||
|
result = await db.execute(select(User).where(User.id == user_id))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
|
user.hashed_password = hash_password(new_password)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/impersonate/{user_id}")
|
@router.post("/impersonate/{user_id}")
|
||||||
async def impersonate_user(
|
async def impersonate_user(
|
||||||
user_id: int,
|
user_id: int,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, Request, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -9,7 +10,7 @@ from app.auth.jwt import (
|
|||||||
hash_password,
|
hash_password,
|
||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from app.dependencies import get_db
|
from app.dependencies import get_db, get_current_user
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.subject import Subject
|
from app.models.subject import Subject
|
||||||
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||||
@@ -58,10 +59,15 @@ async def register(body: RegisterRequest, response: Response, db: AsyncSession =
|
|||||||
|
|
||||||
@router.post("/login", response_model=TokenResponse)
|
@router.post("/login", response_model=TokenResponse)
|
||||||
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
async def login(body: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
|
||||||
result = await db.execute(select(User).where(User.email == body.email, User.is_active == True))
|
result = await db.execute(select(User).where(User.email == body.email))
|
||||||
user = result.scalar_one_or_none()
|
user = result.scalar_one_or_none()
|
||||||
if not user or not verify_password(body.password, user.hashed_password):
|
if not user or not verify_password(body.password, user.hashed_password):
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="This account has been disabled. Please contact your administrator.")
|
||||||
|
|
||||||
|
user.last_active_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
access = create_access_token({"sub": str(user.id)})
|
access = create_access_token({"sub": str(user.id)})
|
||||||
refresh = create_refresh_token({"sub": str(user.id)})
|
refresh = create_refresh_token({"sub": str(user.id)})
|
||||||
@@ -89,6 +95,9 @@ async def refresh_token(request: Request, response: Response, db: AsyncSession =
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="User not found")
|
raise HTTPException(status_code=401, detail="User not found")
|
||||||
|
|
||||||
|
user.last_active_at = datetime.utcnow()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
access = create_access_token({"sub": str(user.id)})
|
access = create_access_token({"sub": str(user.id)})
|
||||||
new_refresh = create_refresh_token({"sub": str(user.id)})
|
new_refresh = create_refresh_token({"sub": str(user.id)})
|
||||||
response.set_cookie(REFRESH_COOKIE, new_refresh, **COOKIE_OPTS)
|
response.set_cookie(REFRESH_COOKIE, new_refresh, **COOKIE_OPTS)
|
||||||
@@ -99,3 +108,20 @@ async def refresh_token(request: Request, response: Response, db: AsyncSession =
|
|||||||
async def logout(response: Response):
|
async def logout(response: Response):
|
||||||
response.delete_cookie(REFRESH_COOKIE)
|
response.delete_cookie(REFRESH_COOKIE)
|
||||||
return {"detail": "Logged out"}
|
return {"detail": "Logged out"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password")
|
||||||
|
async def change_password(
|
||||||
|
body: dict,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
current = body.get("current_password", "")
|
||||||
|
new = body.get("new_password", "").strip()
|
||||||
|
if not current or not new:
|
||||||
|
raise HTTPException(status_code=400, detail="current_password and new_password are required")
|
||||||
|
if not verify_password(current, current_user.hashed_password):
|
||||||
|
raise HTTPException(status_code=400, detail="Current password is incorrect")
|
||||||
|
current_user.hashed_password = hash_password(new)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -13,6 +16,15 @@ from app.websocket.manager import manager
|
|||||||
router = APIRouter(prefix="/api/children", tags=["children"])
|
router = APIRouter(prefix="/api/children", tags=["children"])
|
||||||
|
|
||||||
|
|
||||||
|
def _today_in_tz(tz_name: str):
|
||||||
|
"""Return today's date in the given IANA timezone, falling back to UTC."""
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_name)
|
||||||
|
except (ZoneInfoNotFoundError, Exception):
|
||||||
|
tz = timezone.utc
|
||||||
|
return datetime.now(tz).date()
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[ChildOut])
|
@router.get("", response_model=list[ChildOut])
|
||||||
async def list_children(
|
async def list_children(
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
@@ -21,7 +33,20 @@ async def list_children(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Child).where(Child.user_id == current_user.id).order_by(Child.name)
|
select(Child).where(Child.user_id == current_user.id).order_by(Child.name)
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
children = result.scalars().all()
|
||||||
|
|
||||||
|
today = _today_in_tz(current_user.timezone)
|
||||||
|
needs_commit = False
|
||||||
|
for child in children:
|
||||||
|
if child.strikes != 0 and child.strikes_last_reset != today:
|
||||||
|
child.strikes = 0
|
||||||
|
child.strikes_last_reset = today
|
||||||
|
needs_commit = True
|
||||||
|
await manager.broadcast(child.id, {"event": "strikes_update", "strikes": 0})
|
||||||
|
if needs_commit:
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=ChildOut, status_code=status.HTTP_201_CREATED)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.models.schedule import ScheduleBlock
|
|||||||
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
|
from app.models.subject import Subject # noqa: F401 — needed for selectinload chain
|
||||||
from app.models.session import DailySession, TimerEvent
|
from app.models.session import DailySession, TimerEvent
|
||||||
from app.schemas.session import DashboardSnapshot
|
from app.schemas.session import DashboardSnapshot
|
||||||
from app.utils.timer import compute_block_elapsed
|
from app.utils.timer import compute_block_elapsed, compute_break_elapsed
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
@@ -65,10 +65,34 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
# Compute elapsed seconds and paused state for the current block from timer_events
|
# Compute elapsed seconds and paused state for the current block from timer_events
|
||||||
is_paused = False
|
is_paused = False
|
||||||
|
is_break_active = False
|
||||||
|
break_elapsed_seconds = 0
|
||||||
|
is_break_paused = False
|
||||||
if session and session.current_block_id:
|
if session and session.current_block_id:
|
||||||
block_elapsed_seconds, is_paused = await compute_block_elapsed(
|
block_elapsed_seconds, is_paused = await compute_block_elapsed(
|
||||||
db, session.id, session.current_block_id
|
db, session.id, session.current_block_id
|
||||||
)
|
)
|
||||||
|
# Determine if break mode is active: check whether the most recent
|
||||||
|
# timer event for this block (main or break) is a break event.
|
||||||
|
last_event_result = await db.execute(
|
||||||
|
select(TimerEvent)
|
||||||
|
.where(
|
||||||
|
TimerEvent.session_id == session.id,
|
||||||
|
TimerEvent.block_id == session.current_block_id,
|
||||||
|
TimerEvent.event_type.in_([
|
||||||
|
"start", "resume",
|
||||||
|
"break_start", "break_resume", "break_pause", "break_reset",
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.order_by(TimerEvent.occurred_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
last_event = last_event_result.scalar_one_or_none()
|
||||||
|
if last_event and last_event.event_type.startswith("break_"):
|
||||||
|
is_break_active = True
|
||||||
|
break_elapsed_seconds, is_break_paused = await compute_break_elapsed(
|
||||||
|
db, session.id, session.current_block_id
|
||||||
|
)
|
||||||
|
|
||||||
routine_result = await db.execute(
|
routine_result = await db.execute(
|
||||||
select(MorningRoutineItem)
|
select(MorningRoutineItem)
|
||||||
@@ -93,4 +117,7 @@ async def get_dashboard(child_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
is_paused=is_paused,
|
is_paused=is_paused,
|
||||||
morning_routine=morning_routine,
|
morning_routine=morning_routine,
|
||||||
break_activities=break_activities,
|
break_activities=break_activities,
|
||||||
|
is_break_active=is_break_active,
|
||||||
|
break_elapsed_seconds=break_elapsed_seconds,
|
||||||
|
is_break_paused=is_break_paused,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async def get_timeline(
|
|||||||
selectinload(DailySession.template),
|
selectinload(DailySession.template),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
.where(TimerEvent.event_type != "select")
|
||||||
.order_by(TimerEvent.occurred_at.desc())
|
.order_by(TimerEvent.occurred_at.desc())
|
||||||
)
|
)
|
||||||
if child_id:
|
if child_id:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from app.models.subject import Subject # noqa: F401 — needed for selectinload
|
|||||||
from app.models.session import DailySession, TimerEvent
|
from app.models.session import DailySession, TimerEvent
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
|
from app.schemas.session import DailySessionOut, SessionStart, TimerAction
|
||||||
|
from sqlalchemy import delete as sql_delete
|
||||||
from app.utils.timer import compute_block_elapsed, compute_break_elapsed
|
from app.utils.timer import compute_block_elapsed, compute_break_elapsed
|
||||||
from app.websocket.manager import manager
|
from app.websocket.manager import manager
|
||||||
|
|
||||||
@@ -194,12 +195,15 @@ async def timer_action(
|
|||||||
|
|
||||||
# When break starts, implicitly pause the main block timer so elapsed
|
# When break starts, implicitly pause the main block timer so elapsed
|
||||||
# time is captured accurately in the activity log and on page reload.
|
# time is captured accurately in the activity log and on page reload.
|
||||||
|
# Only write the pause if the block is actually running.
|
||||||
if body.event_type == "break_start" and block_id:
|
if body.event_type == "break_start" and block_id:
|
||||||
db.add(TimerEvent(
|
_, already_paused = await compute_block_elapsed(db, session.id, block_id)
|
||||||
session_id=session.id,
|
if not already_paused:
|
||||||
block_id=block_id,
|
db.add(TimerEvent(
|
||||||
event_type="pause",
|
session_id=session.id,
|
||||||
))
|
block_id=block_id,
|
||||||
|
event_type="pause",
|
||||||
|
))
|
||||||
|
|
||||||
db.add(TimerEvent(
|
db.add(TimerEvent(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
@@ -235,32 +239,48 @@ async def timer_action(
|
|||||||
if body.event_type in ("start", "select", "reset") and body.block_id is not None:
|
if body.event_type in ("start", "select", "reset") and body.block_id is not None:
|
||||||
prev_block_id = session.current_block_id
|
prev_block_id = session.current_block_id
|
||||||
if prev_block_id and prev_block_id != body.block_id:
|
if prev_block_id and prev_block_id != body.block_id:
|
||||||
db.add(TimerEvent(
|
prev_block_elapsed_seconds, prev_already_paused = await compute_block_elapsed(
|
||||||
session_id=session.id,
|
|
||||||
block_id=prev_block_id,
|
|
||||||
event_type="pause",
|
|
||||||
))
|
|
||||||
# Autoflush means the implicit pause above is visible to the helper.
|
|
||||||
prev_block_elapsed_seconds, _ = await compute_block_elapsed(
|
|
||||||
db, session.id, prev_block_id
|
db, session.id, prev_block_id
|
||||||
)
|
)
|
||||||
|
# Only write an implicit pause if the previous block was actually running.
|
||||||
|
if not prev_already_paused:
|
||||||
|
db.add(TimerEvent(
|
||||||
|
session_id=session.id,
|
||||||
|
block_id=prev_block_id,
|
||||||
|
event_type="pause",
|
||||||
|
))
|
||||||
|
# Recompute elapsed now that the pause event is included.
|
||||||
|
prev_block_elapsed_seconds, _ = await compute_block_elapsed(
|
||||||
|
db, session.id, prev_block_id
|
||||||
|
)
|
||||||
|
|
||||||
# Update current block if provided
|
# Update current block if provided
|
||||||
if body.block_id is not None:
|
if body.block_id is not None:
|
||||||
session.current_block_id = body.block_id
|
session.current_block_id = body.block_id
|
||||||
|
|
||||||
# Record the timer event
|
# Record the timer event (select events are not persisted — they only drive WS broadcasts)
|
||||||
event = TimerEvent(
|
event = TimerEvent(
|
||||||
session_id=session.id,
|
session_id=session.id,
|
||||||
block_id=body.block_id or session.current_block_id,
|
block_id=body.block_id or session.current_block_id,
|
||||||
event_type=body.event_type,
|
event_type=body.event_type,
|
||||||
)
|
)
|
||||||
db.add(event)
|
if body.event_type != "select":
|
||||||
|
db.add(event)
|
||||||
|
|
||||||
# Mark session complete if event is session-level complete
|
# Mark session complete if event is session-level complete
|
||||||
if body.event_type == "complete" and body.block_id is None:
|
if body.event_type == "complete" and body.block_id is None:
|
||||||
session.is_active = False
|
session.is_active = False
|
||||||
|
|
||||||
|
# Reset removes completed status — delete any complete events for this block
|
||||||
|
if body.event_type == "reset" and event.block_id:
|
||||||
|
await db.execute(
|
||||||
|
sql_delete(TimerEvent).where(
|
||||||
|
TimerEvent.session_id == session.id,
|
||||||
|
TimerEvent.block_id == event.block_id,
|
||||||
|
TimerEvent.event_type == "complete",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(session)
|
await db.refresh(session)
|
||||||
|
|
||||||
@@ -283,6 +303,7 @@ async def timer_action(
|
|||||||
"block_elapsed_seconds": block_elapsed_seconds,
|
"block_elapsed_seconds": block_elapsed_seconds,
|
||||||
"prev_block_id": prev_block_id,
|
"prev_block_id": prev_block_id,
|
||||||
"prev_block_elapsed_seconds": prev_block_elapsed_seconds,
|
"prev_block_elapsed_seconds": prev_block_elapsed_seconds,
|
||||||
|
"uncomplete_block_id": event.block_id if body.event_type == "reset" else None,
|
||||||
}
|
}
|
||||||
await manager.broadcast(session.child_id, ws_payload)
|
await manager.broadcast(session.child_id, ws_payload)
|
||||||
|
|
||||||
|
|||||||
@@ -46,3 +46,6 @@ class DashboardSnapshot(BaseModel):
|
|||||||
is_paused: bool = False # whether the current block's timer is paused
|
is_paused: bool = False # whether the current block's timer is paused
|
||||||
morning_routine: list[str] = [] # text items shown on TV during greeting state
|
morning_routine: list[str] = [] # text items shown on TV during greeting state
|
||||||
break_activities: list[str] = [] # text items shown on TV during break time
|
break_activities: list[str] = [] # text items shown on TV during break time
|
||||||
|
is_break_active: bool = False # whether break mode is currently active
|
||||||
|
break_elapsed_seconds: int = 0 # seconds already elapsed in the break timer
|
||||||
|
is_break_paused: bool = False # whether the break timer is paused
|
||||||
|
|||||||
@@ -31,16 +31,19 @@ async def compute_block_elapsed(
|
|||||||
for e in tick_events:
|
for e in tick_events:
|
||||||
if e.event_type == "reset":
|
if e.event_type == "reset":
|
||||||
elapsed = 0.0
|
elapsed = 0.0
|
||||||
last_start = e.occurred_at
|
last_start = None
|
||||||
elif e.event_type in ("start", "resume"):
|
elif e.event_type in ("start", "resume"):
|
||||||
last_start = e.occurred_at
|
last_start = e.occurred_at
|
||||||
elif e.event_type == "pause" and last_start:
|
elif e.event_type == "pause" and last_start:
|
||||||
elapsed += (e.occurred_at - last_start).total_seconds()
|
elapsed += (e.occurred_at - last_start).total_seconds()
|
||||||
last_start = None
|
last_start = None
|
||||||
if last_start:
|
running = last_start is not None
|
||||||
|
if running:
|
||||||
elapsed += (datetime.utcnow() - last_start).total_seconds()
|
elapsed += (datetime.utcnow() - last_start).total_seconds()
|
||||||
|
|
||||||
is_paused = bool(tick_events) and tick_events[-1].event_type == "pause"
|
# is_paused is True whenever the timer is not actively running —
|
||||||
|
# covers: explicitly paused, never started, or only selected.
|
||||||
|
is_paused = not running
|
||||||
return int(elapsed), is_paused
|
return int(elapsed), is_paused
|
||||||
|
|
||||||
|
|
||||||
@@ -70,8 +73,9 @@ async def compute_break_elapsed(
|
|||||||
elif e.event_type == "break_pause" and last_start:
|
elif e.event_type == "break_pause" and last_start:
|
||||||
elapsed += (e.occurred_at - last_start).total_seconds()
|
elapsed += (e.occurred_at - last_start).total_seconds()
|
||||||
last_start = None
|
last_start = None
|
||||||
if last_start:
|
running = last_start is not None
|
||||||
|
if running:
|
||||||
elapsed += (datetime.utcnow() - last_start).total_seconds()
|
elapsed += (datetime.utcnow() - last_start).total_seconds()
|
||||||
|
|
||||||
is_paused = bool(tick_events) and tick_events[-1].event_type == "break_pause"
|
is_paused = not running
|
||||||
return int(elapsed), is_paused
|
return int(elapsed), is_paused
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Homeschool Dashboard</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -48,20 +48,35 @@ export const useScheduleStore = defineStore('schedule', () => {
|
|||||||
// Restore elapsed time from server-computed value and seed the per-block cache
|
// Restore elapsed time from server-computed value and seed the per-block cache
|
||||||
const serverElapsed = snapshot.block_elapsed_seconds || 0
|
const serverElapsed = snapshot.block_elapsed_seconds || 0
|
||||||
if (snapshot.session?.current_block_id) {
|
if (snapshot.session?.current_block_id) {
|
||||||
blockElapsedCache.value[snapshot.session.current_block_id] = serverElapsed
|
const blockId = snapshot.session.current_block_id
|
||||||
blockElapsedOffset.value = serverElapsed
|
// If the current block is already completed (Done was pressed), snap elapsed
|
||||||
// Start the live counter only when the block is actually running (not paused).
|
// to the full block duration so the timer shows 00:00 / Done! on reload.
|
||||||
// Use serverElapsed == 0 is fine here — a just-reset block is still running.
|
const isCompleted = completedBlockIds.value.includes(blockId)
|
||||||
blockStartedAt.value = isPaused.value ? null : Date.now()
|
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 {
|
} else {
|
||||||
blockElapsedOffset.value = 0
|
blockElapsedOffset.value = 0
|
||||||
blockStartedAt.value = null
|
blockStartedAt.value = null
|
||||||
}
|
}
|
||||||
// Reset break state on snapshot (not persisted across page loads)
|
// Restore break state from server
|
||||||
isBreakMode.value = false
|
if (snapshot.is_break_active && snapshot.session?.current_block_id) {
|
||||||
breakStartedAt.value = null
|
const blockId = snapshot.session.current_block_id
|
||||||
breakElapsedOffset.value = 0
|
const breakElapsed = snapshot.break_elapsed_seconds || 0
|
||||||
breakElapsedCache.value = {}
|
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) {
|
function applyWsEvent(event) {
|
||||||
@@ -150,12 +165,15 @@ export const useScheduleStore = defineStore('schedule', () => {
|
|||||||
breakStartedAt.value = null
|
breakStartedAt.value = null
|
||||||
breakElapsedOffset.value = breakElapsedCache.value[event.block_id] ?? 0
|
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.event === 'reset') {
|
||||||
if (event.block_id) blockElapsedCache.value[event.block_id] = 0
|
if (event.block_id) blockElapsedCache.value[event.block_id] = 0
|
||||||
blockElapsedOffset.value = 0
|
blockElapsedOffset.value = 0
|
||||||
blockStartedAt.value = Date.now()
|
blockStartedAt.value = null
|
||||||
isPaused.value = false
|
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)
|
// Select — switch current block but keep timer stopped (manual start required)
|
||||||
if (event.event === 'select') {
|
if (event.event === 'select') {
|
||||||
@@ -337,14 +355,27 @@ export const useScheduleStore = defineStore('schedule', () => {
|
|||||||
sendTimerAction(sessionId, 'break_reset', blockId)
|
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.
|
// Reset the current block's timer to 0 and start counting immediately.
|
||||||
function resetCurrentBlock(sessionId) {
|
function resetCurrentBlock(sessionId) {
|
||||||
if (!session.value?.current_block_id) return
|
if (!session.value?.current_block_id) return
|
||||||
const blockId = session.value.current_block_id
|
const blockId = session.value.current_block_id
|
||||||
blockElapsedCache.value[blockId] = 0
|
blockElapsedCache.value[blockId] = 0
|
||||||
blockElapsedOffset.value = 0
|
blockElapsedOffset.value = 0
|
||||||
isPaused.value = false
|
isPaused.value = true
|
||||||
blockStartedAt.value = Date.now()
|
blockStartedAt.value = null
|
||||||
|
completedBlockIds.value = completedBlockIds.value.filter(id => id !== blockId)
|
||||||
sendTimerAction(sessionId, 'reset', blockId)
|
sendTimerAction(sessionId, 'reset', blockId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,6 +405,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
|||||||
selectBlock,
|
selectBlock,
|
||||||
startCurrentBlock,
|
startCurrentBlock,
|
||||||
resumeCurrentBlock,
|
resumeCurrentBlock,
|
||||||
|
markBlockDone,
|
||||||
resetCurrentBlock,
|
resetCurrentBlock,
|
||||||
startBreak,
|
startBreak,
|
||||||
pauseBreak,
|
pauseBreak,
|
||||||
|
|||||||
@@ -343,10 +343,43 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -373,6 +406,45 @@ async function saveTimezone() {
|
|||||||
setTimeout(() => { tzSaved.value = false }, 2000)
|
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
|
// Children
|
||||||
const showChildForm = ref(false)
|
const showChildForm = ref(false)
|
||||||
const newChild = ref({ name: '', color: '#4F46E5' })
|
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-label { flex: 1; min-width: 180px; }
|
||||||
.settings-title { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.2rem; }
|
.settings-title { font-size: 0.95rem; font-weight: 500; margin-bottom: 0.2rem; }
|
||||||
.settings-hint { font-size: 0.8rem; color: #64748b; }
|
.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; }
|
.settings-control { display: flex; align-items: center; gap: 0.75rem; flex-shrink: 0; }
|
||||||
.tz-select {
|
.tz-select {
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
@@ -895,4 +968,40 @@ h2 { font-size: 1.1rem; color: #94a3b8; text-transform: uppercase; letter-spacin
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.break-check-label input[type="checkbox"] { cursor: pointer; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -74,28 +74,31 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="session-actions">
|
<div class="session-actions">
|
||||||
<div class="session-actions-left">
|
<button
|
||||||
<button
|
class="btn-sm"
|
||||||
class="btn-sm"
|
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
|
||||||
v-if="scheduleStore.session.current_block_id && !scheduleStore.isPaused"
|
@click="sendAction('pause')"
|
||||||
@click="sendAction('pause')"
|
>Pause</button>
|
||||||
>Pause</button>
|
<button
|
||||||
<button
|
class="btn-sm btn-start"
|
||||||
class="btn-sm btn-start"
|
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
|
||||||
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset === 0 && scheduleStore.session.current_block_id"
|
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
|
||||||
@click="scheduleStore.startCurrentBlock(scheduleStore.session.id)"
|
>Start</button>
|
||||||
>Start</button>
|
<button
|
||||||
<button
|
class="btn-sm"
|
||||||
class="btn-sm"
|
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
|
||||||
v-if="scheduleStore.isPaused && scheduleStore.blockElapsedOffset > 0"
|
@click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
|
||||||
@click="scheduleStore.resumeCurrentBlock(scheduleStore.session.id)"
|
>Resume</button>
|
||||||
>Resume</button>
|
<button
|
||||||
<button
|
class="btn-sm"
|
||||||
class="btn-sm"
|
v-if="scheduleStore.session.current_block_id"
|
||||||
v-if="scheduleStore.session.current_block_id"
|
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
|
||||||
@click="scheduleStore.resetCurrentBlock(scheduleStore.session.id)"
|
>Reset</button>
|
||||||
>Reset</button>
|
<button
|
||||||
</div>
|
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>
|
<button class="btn-sm btn-danger" @click="sendAction('complete')">End Day</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -419,11 +422,11 @@ h1 { font-size: 1.75rem; font-weight: 700; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.current-block-timer { display: flex; justify-content: center; margin: 1rem 0; }
|
.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 { display: flex; align-items: center; margin-top: 1rem; gap: 0.5rem; }
|
||||||
.session-actions-left { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
.session-actions .btn-sm { flex: 1; text-align: center; }
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
padding: 0.4rem 0.9rem;
|
padding: 0.8rem 0.9rem;
|
||||||
border: 1px solid #334155;
|
border: 1px solid #334155;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
<option value="resume">↺ Resumed</option>
|
<option value="resume">↺ Resumed</option>
|
||||||
<option value="complete">✓ Completed</option>
|
<option value="complete">✓ Completed</option>
|
||||||
<option value="skip">⟶ Skipped</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="edit-field">
|
<div class="edit-field">
|
||||||
@@ -199,6 +204,11 @@ const EVENT_META = {
|
|||||||
resume: { icon: '↺', label: 'Resumed' },
|
resume: { icon: '↺', label: 'Resumed' },
|
||||||
complete: { icon: '✓', label: 'Completed' },
|
complete: { icon: '✓', label: 'Completed' },
|
||||||
skip: { icon: '⟶', label: 'Skipped' },
|
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) {
|
function eventIcon(entry) {
|
||||||
@@ -217,6 +227,7 @@ function eventLabel(entry) {
|
|||||||
: `Strike removed (${entry.new_strikes}/3)`
|
: `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 '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
|
const action = EVENT_META[entry.event_type]?.label || entry.event_type
|
||||||
return entry.block_label ? `${action} — ${entry.block_label}` : action
|
return entry.block_label ? `${action} — ${entry.block_label}` : action
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,15 +19,69 @@
|
|||||||
{{ u.is_active ? 'Active' : 'Inactive' }}
|
{{ u.is_active ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="joined">Joined {{ formatDate(u.created_at) }}</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>
|
||||||
</div>
|
</div>
|
||||||
<button class="enter-btn" :disabled="entering === u.id" @click="enter(u.id)">
|
<div class="card-actions">
|
||||||
{{ entering === u.id ? 'Entering…' : 'Enter as User' }}
|
<button class="enter-btn" :disabled="entering === u.id || !u.is_active" @click="enter(u.id)">
|
||||||
</button>
|
{{ 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>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -43,6 +97,82 @@ const users = ref([])
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const entering = ref(null)
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -73,9 +203,11 @@ function handleLogout() {
|
|||||||
router.push('/super-admin/login')
|
router.push('/super-admin/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso) {
|
function formatDate(iso, timezone) {
|
||||||
if (!iso) return '—'
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -192,6 +324,49 @@ h2 {
|
|||||||
font-size: 0.8rem;
|
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 {
|
.enter-btn {
|
||||||
padding: 0.45rem 1rem;
|
padding: 0.45rem 1rem;
|
||||||
background: #f59e0b;
|
background: #f59e0b;
|
||||||
@@ -208,4 +383,131 @@ h2 {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user