This commit is contained in:
Nathan Anderson 2026-01-27 13:26:45 -06:00
parent e22c7e49a2
commit 1df84f59a4
20 changed files with 0 additions and 6237 deletions

304
TODO.md
View File

@ -1,304 +0,0 @@
# CyberHybridHub TODO
## High Priority 🔴
### Error Handling Improvements
- [ ] **Replace console.log statements with proper logging**
- routes/api/payments/set_auto_pay.ts (line 80)
- routes/api/payments/cancel_auto_pay.ts (line 65)
- routes/tenant/settings/index.tsx (line 50)
- routes/tenant/payment-methods/index.tsx (lines 33, 43)
- Use utils/logging.ts functions (writeAudioLog, writeImageLog, logBlobFetch) or create new logging utilities
- [ ] **Standardize error handling across API routes**
- Many catch blocks use generic error handling without proper logging
- Some routes return `JSON.stringify(e)` which may not be safe
- Create proper error classes and error types
- Implement consistent error response format
- [ ] **Add proper error logging infrastructure**
- Extend utils/logging.ts with general error logging functions
- Add request ID tracking for better debugging
- Implement structured logging with log levels (error, warn, info, debug)
### TypeScript Type Safety
- [ ] **Replace `any` types with proper TypeScript types**
- **Utils & Types:**
- utils/request_handler.ts (line 7)
- types/graph/ServicePrincipal.ts (lines 34, 43, 45, 46)
- utils/TextToQuizTranslations.ts (line 33)
- db/interfaces.ts (lines 60, 204, 251)
- **Islands:**
- islands/AsyncImageGenerater.tsx (line 35)
- islands/ClearCheckedButton.tsx (lines 4-5)
- islands/SyncButton.tsx (line 7)
- islands/ArrangeQuiz.tsx (lines 145, 190, 235, 254)
- islands/Card.tsx (line 332)
- islands/StudyButton.tsx (line 7)
- islands/AdminPage.tsx (line 187)
- islands/dialogs/AddRentalDialog.tsx (line 4)
- islands/dialogs/AddLeaseDialog.tsx (line 4)
- islands/TranslationList.tsx (line 273)
- islands/UserDashboard.tsx (lines 1136, 1167, 1196, 1235, 1264, 1349)
- **Routes:**
- routes/auth/validate.ts (line 96)
- routes/landlord/onboarding/profile.tsx (line 111)
- routes/api/update/payment_method.ts (line 51)
- routes/landlord/maintenance/index.tsx (line 68)
- **Services & Modules:**
- services/stateMiddleware.ts (line 104)
- modules/KvCacheStore.ts (lines 635, 636, 839, 850, 858)
- **Tests:**
- tests/e2e/helpers.ts (line 12)
- [ ] **Add proper type definitions for event handlers**
- Create shared types for form event handlers
- Define proper types for onChange, onSubmit, etc.
- Use Preact's specific event types
## Medium Priority 🟡
### API Endpoints & Services
- [ ] **Create reminder API endpoints**
- POST /api/create/reminder - Create new reminder
- GET /api/retrieve/reminders - Get reminders for user
- PUT /api/update/reminder - Update reminder
- DELETE /api/delete/reminder - Delete reminder
- PUT /api/update/reminder/complete - Mark reminder as completed
- Service exists: services/reminderService.ts
- [ ] **Create financial data API endpoints**
- GET /api/retrieve/obligations - Get user obligations
- GET /api/retrieve/incomes - Get user incomes
- Service exists: services/financhalServices.ts
- [ ] **Verify all API endpoints have proper error handling**
- Review all routes/api/create/* files
- Review all routes/api/update/* files
- Review all routes/api/delete/* files
- Review all routes/api/retrieve/* files
- Ensure consistent error response format
### Code Quality & Testing
- [ ] **Run and fix TypeScript compilation errors**
- Run `deno check` to identify issues
- Fix all type mismatches
- Ensure all files pass type checking
- [ ] **Validate all changed files**
- Run `deno fmt` for formatting
- Run `deno lint` for code quality
- Run `deno check` for type checking
- [ ] **Add unit tests for services**
- services/paymentService.ts
- services/leaseService.ts
- services/reminderService.ts
- services/financhalServices.ts
- services/tenantInviteService.ts
- [ ] **Add integration tests for API endpoints**
- Test all create endpoints
- Test all update endpoints
- Test all delete endpoints
- Test all retrieve endpoints
### Database & Schema
- [ ] **Verify database schema supports all features**
- Check lease table supports required states (new, signed, active)
- Verify tenant_id column is nullable in lease table
- Confirm all required fields exist for reminders
- Validate financial tables (obligations, incomes) schema
- [ ] **Add database migration documentation**
- Document existing migrations in drizzle/ directory
- Add migration rollback procedures
- Document schema evolution
## Low Priority 🟢
### Documentation
- [ ] **Update README.md with setup instructions**
- Add environment variable configuration guide
- Document all required environment variables
- Add troubleshooting section
- [ ] **Add API documentation**
- Document all API endpoints
- Include request/response examples
- Add authentication requirements
- [ ] **Create developer onboarding guide**
- Project structure overview
- Development workflow
- Testing guidelines
- Code style guidelines
### Performance & Optimization
- [ ] **Optimize database queries**
- Review all service functions for N+1 queries
- Add proper indexes where needed
- Implement query result caching
- [ ] **Add request/response caching**
- Cache frequently accessed data
- Implement cache invalidation strategy
- Use Deno KV for caching (already configured)
- [ ] **Optimize bundle size**
- Analyze production bundle
- Remove unused dependencies
- Implement code splitting
### Security
- [ ] **Add input validation middleware**
- Validate all incoming request data
- Sanitize user inputs
- Prevent injection attacks
- [ ] **Add rate limiting**
- Implement rate limiting for API endpoints
- Add rate limiting for authentication endpoints
- Configure rate limits per endpoint type
- [ ] **Add security headers**
- Content Security Policy (CSP)
- X-Frame-Options
- X-Content-Type-Options
- Strict-Transport-Security (HSTS)
## Completed Items ✅
### Lease Creation Feature Implementation
- [x] **Implement `getTransactionByStripeId(stripeId: string)`** in `services/paymentService.ts`
- ✅ EXISTS at line 391
- Used by routes/api/receipt/[id].ts and routes/api/payments/webhook.ts
- [x] **POST `/api/leases`** - Create new lease endpoint
- ✅ EXISTS at routes/api/create/lease.ts
- Request body validation (rent, deposit, propertyId, dates)
- Database insertion with `new` state
- Returns created lease object
- [x] **PUT `/api/leases/:id/invite`** - Send tenant invitation
- ✅ EXISTS at routes/api/update/lease_invite.ts
- Generates invitation token
- Creates tenant invite
- Updates lease status
- [x] **PUT `/api/leases/:id/accept`** - Tenant accepts invitation
- ✅ EXISTS at routes/api/update/lease_accept.ts
- Validates invitation token
- Updates lease tenant field and status to `signed`
- [x] **Create lease creation form component**
- ✅ AddLeaseDialog.tsx exists
- ✅ PopAddLeaseDialog.tsx exists
- Rent amount input field
- Security deposit amount input field
- Property selection dropdown
- Lease term start/end date pickers
- Form validation and error handling
- [x] **Add "Create New Lease" button on landlord dashboard**
- ✅ Available at /leased-home route
- ✅ LeasedHome island component exists
- [x] **Tenant invitation interface**
- ✅ LandlordInviteTenant.tsx exists
- Email input and invitation sending UI
## Next Steps
2. Replace console.log statements with proper logging
3. Standardize error handling across all API routes
4. Replace TypeScript any types with proper types
5. Create missing API endpoints for reminders and financial data
6. Add comprehensive error logging infrastructure
7. Run deno check, fmt, and lint to fix code quality issues
8. Add unit and integration tests for services and API endpoints
9. Update documentation with setup and API guides
10. Implement performance optimizations and security enhancements
#### Frontend Components
- [ ] **Create lease creation form component** (`components/LeaseForm.tsx`)
- Rent amount input field
- Security deposit amount input field
- Property selection dropdown
- Lease term start/end date pickers
- Form validation and error handling
- [ ] **Add "Create New Lease" button next to "Add Property" button** on landlord dashboard (`/dashboard/landlord` route)
- [ ] **Create lease creation modal/page** (`routes/landlord/create-lease.tsx`)
- [ ] **Tenant invitation interface** - Email input and invitation sending UI
#### Backend API Endpoints
- [ ] **POST `/api/leases`** - Create new lease endpoint
- Request body validation (rent, deposit, propertyId, dates)
- Database insertion with `new` state
- Return created lease object
- [ ] **PUT `/api/leases/:id/invite`** - Send tenant invitation
- Generate invitation token
- Send email to tenant
- Update lease status
- [ ] **PUT `/api/leases/:id/accept`** - Tenant accepts invitation
- Validate invitation token
- Update lease tenant field and status to `signed`
## Medium Priority 🟡
### Payment Service Fixes
- [ ] **Implement `getTransactionByStripeId(stripeId: string)`** in `services/paymentService.ts`
- Query database by stripe transaction ID
- Return transaction object or null
- [ ] **Fix TypeScript import errors** in receipt API routes
- Check all `routes/api/receipts/` files
- Fix relative import paths
- [ ] **Add proper TypeScript types** for Fresh route handlers
- Define request/response types for API endpoints
- Update handler function signatures
### Code Quality & Testing
- [ ] **Fix TypeScript compilation errors**
- Run `deno check` to identify issues
- Fix all type mismatches
- [ ] **Validate all changed files**
- Run `deno fmt` for formatting
- Run `deno lint` for code quality
- Run `deno check` for type checking
- [ ] **Test lease creation workflow end-to-end**
- Manual testing in development environment
- Verify database state changes
- Test tenant invitation flow
## Low Priority 🟢
### Testing Infrastructure
- [ ] **Unit tests for lease creation** (`tests/unit/lease.test.ts`)
- [ ] **Integration tests for tenant invitation flow** (`tests/integration/invitation.test.ts`)
- [ ] **E2E tests for complete lease lifecycle** (`tests/e2e/lease-lifecycle.spec.ts`)
- [ ] **Payment service integration tests** (`tests/integration/payment.test.ts`)
## Database Schema Verification Needed
- [ ] **Verify lease table supports required states**
- Check `state` column accepts `new` and `signed`
- Verify `tenant_id` column is nullable
- Confirm all required fields exist
## Current Blockers
- ❌ **TypeScript compilation errors** preventing development
- ❌ **Missing payment service functions** breaking receipt functionality
## Next Steps
1. Fix immediate TypeScript and payment service issues
2. Implement lease creation API endpoints
3. Create frontend components for lease management
4. Test complete workflow end-to-end

View File

@ -1,372 +0,0 @@
// islands/QuizConfigure.tsx
import { useEffect, useState } from "preact/hooks";
import { QuizConfig } from "../utils/types.ts";
import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx";
export interface ArrangementProps {
quizConfig: QuizConfig;
// kvObj: KvObj<QuizConfig>;
quizOrder: { index: number; slots: string[] };
quizMode: string;
tabs: string[];
}
const NEW_TAB_VALUE = "__new_tab__";
export default function ArrangeQuiz(
{ quizConfig, quizOrder, quizMode, tabs }: ArrangementProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [ask, popupElement] = AsyncYesNoPop();
const [thisQuizConfig, setQuizConfig] = useState<QuizConfig>(quizConfig);
const [orderedQuizStr, setOrderQuizStr] = useState<string>(
JSON.stringify(thisQuizConfig),
);
const [thisQuizConfigStr, setQuizConfigStr] = useState<string>(
JSON.stringify({
theUpdate: null,
quiz: quizConfig,
//kvObj: kvObj,
quizOrder: quizOrder,
}),
);
const [selectedSlot, setSelectedSlot] = useState(0);
const [thisQuizOder, setThisQuizOrder] = useState(quizOrder);
const [randomOrder, setRandomOrder] = useState(true);
const [modeType, setModeType] = useState(quizMode);
//const [isLoading, setIsLoading] = useState(true);
// Tab selection state
const [selectedTabIndex, setSelectedTabIndex] = useState<
number | typeof NEW_TAB_VALUE
>(-1);
const [newTabName, setNewTabName] = useState("");
const isNewTab = selectedTabIndex === NEW_TAB_VALUE;
const isExistingTab = typeof selectedTabIndex === "number" &&
selectedTabIndex >= 0;
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prev) => {
const newCount = prev + 1;
return newCount;
});
};
const handleSaveQuiz = async (event: Event) => {
//console.log("Clicked save");
//if (changeMode && changeMode.mode) {
// setQuizConfigStr(JSON.stringify({ theUpdate:changeMode.mode, quiz: thisQuizConfig, kvObj:kvObj,quizOrder:thisQuizOder }));
// handleClick();
event.preventDefault();
//value={JSON.stringify({ theUpdate:null, quiz: quiz , kvObj:quizProps[index] ? cloneObjectExcept(quizProps[index],["value"]) as KvObj<QuizConfig>: null})}
const form = event.currentTarget as HTMLFormElement;
const userConfirmed = await ask(
"Do you want to save this change?",
Date.now(),
);
if (userConfirmed) {
form.submit();
}
//const userConfirmed = window.confirm("Do you wanted to save changes?");
// if (userConfirmed) {
// (event.currentTarget as HTMLFormElement).submit();
// }
//} else {
// event.preventDefault();
//}
};
useEffect(() => {
//console.log("setting mode!!",quizConfig.quiz_mode)
if (quizConfig.quiz_mode) {
setModeType(quizConfig.quiz_mode);
}
}, []);
useEffect(() => {
// Initialize selectedTabIndex to 0 when tabs are available and no tab is selected
if (tabs.length > 0 && selectedTabIndex === -1) {
setSelectedTabIndex(0);
}
}, [tabs, selectedTabIndex]);
return (
<>
{popupElement}
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
>
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
<h2 className="text-2xl font-bold mb-4 theme-text">
Arrange Quizzes
</h2>
<div className="theme-secondary-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto">
<h2 className="text-2xl font-bold mb-4 theme-text">
Dashboard Quiz List
</h2>
<form
action="/language/arrange"
method="POST"
onSubmit={handleSaveQuiz}
>
<input
type="hidden"
name="quizConfigData"
value={thisQuizConfigStr}
/>
<input
type="hidden"
name="quizMode"
value={quizMode}
/>
{/* Step 1: Select a quiz */}
<div className="mb-4">
<label
htmlFor="quizSelect"
className="block mb-2 theme-text"
>
Select Quiz
</label>
<select
id="quizSelect"
value={thisQuizOder.index}
onChange={(e: any) => {
const newIndex = parseInt(e.target.value, 10);
setSelectedSlot(newIndex);
thisQuizOder.index = newIndex;
setThisQuizOrder({ ...thisQuizOder });
// Reset tab selection when quiz changes
setSelectedTabIndex(-1);
setNewTabName("");
setQuizConfigStr(
JSON.stringify({
theUpdate: newIndex,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
}),
);
}}
className="w-full px-3 py-2 rounded-md theme-select"
>
<option value={-1}>Select a quiz to move</option>
{thisQuizOder.slots.map((name: string, i: number) => (
<option
key={i}
className="text-black"
value={i}
>
{`${i} - ${name}`}
</option>
))}
</select>
</div>
{/* Step 2: Select a tab (only shown after quiz is selected) */}
{thisQuizOder.index != -1 && (
<div className="mb-4">
<label
htmlFor="tabSelect"
className="block mb-2 theme-text"
>
Select Tab
</label>
<select
id="tabSelect"
value={selectedTabIndex === NEW_TAB_VALUE
? NEW_TAB_VALUE
: selectedTabIndex}
onChange={(e: any) => {
const value = e.target.value;
if (value === NEW_TAB_VALUE) {
setSelectedTabIndex(NEW_TAB_VALUE);
setNewTabName("");
} else {
setSelectedTabIndex(parseInt(value, 10));
setNewTabName("");
}
}}
className="w-full px-3 py-2 rounded-md theme-select"
>
<option value={-1}>Select a tab</option>
{tabs.map((tabName: string, i: number) => (
<option
key={i}
className="text-black"
value={i}
>
{tabName}
</option>
))}
<option
value={NEW_TAB_VALUE}
className="text-black font-semibold"
>
+ New Tab
</option>
</select>
</div>
)}
{/* Step 3a: New tab name input (only shown if "New Tab" is selected) */}
{thisQuizOder.index != -1 && isNewTab && (
<div className="mb-4">
<label
htmlFor="newTabName"
className="block mb-2 theme-text"
>
New Tab Name
</label>
<input
type="text"
id="newTabName"
value={newTabName}
onChange={(e: any) => setNewTabName(e.target.value)}
placeholder="Enter new tab name"
className="w-full px-3 py-2 rounded-md theme-input border theme-border"
/>
</div>
)}
{/* Step 3b: Position selector (only shown if existing tab is selected) */}
{thisQuizOder.index != -1 && isExistingTab && (
<div className="mb-4">
<label
htmlFor="quizOrder"
className="block mb-2 theme-text"
>
Select Position in Tab
</label>
<select
id="quizOrder"
value={selectedSlot}
onChange={(e: any) => {
setSelectedSlot(parseInt(e.target.value, 10));
setQuizConfigStr(
JSON.stringify({
theUpdate: parseInt(e.target.value, 10),
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
}),
);
}}
className="w-full px-3 py-2 rounded-md theme-select"
>
{thisQuizOder.slots.map((name: string, i: number) => (
<option
key={i}
className="text-black"
value={i}
>
{`Position ${i + 1}`}
</option>
))}
</select>
</div>
)}
{/* Save button for moving to new tab */}
{thisQuizOder.index != -1 && isNewTab &&
newTabName.trim().length > 0 && (
<div className="flex space-x-2 mt-4">
<button
type="submit"
className="theme-button px-4 py-2 rounded w-full"
onClick={() => {
setQuizConfigStr(
JSON.stringify({
theUpdate: thisQuizOder.index,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
tabAction: "new",
newTabName: newTabName.trim(),
newTabIndex: tabs.length,
}),
);
}}
>
Move to New Tab
</button>
</div>
)}
{/* Save button for reordering position within tab */}
{thisQuizOder.index != -1 && isExistingTab &&
thisQuizOder.index !== selectedSlot && (
<div className="flex space-x-2 mt-4">
<button
type="submit"
className="theme-button px-4 py-2 rounded w-full"
onClick={() => {
setQuizConfigStr(
JSON.stringify({
theUpdate: selectedSlot,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
tabAction: "reorder",
targetTabIndex: selectedTabIndex,
}),
);
}}
>
Save Position Change
</button>
</div>
)}
{/* Save button for moving to different existing tab (same position) */}
{thisQuizOder.index != -1 && isExistingTab &&
thisQuizOder.index === selectedSlot && (
<div className="flex space-x-2 mt-4">
<button
type="submit"
className="theme-button px-4 py-2 rounded w-full"
onClick={() => {
setQuizConfigStr(
JSON.stringify({
theUpdate: thisQuizOder.index,
quiz: thisQuizConfig,
quizOrder: thisQuizOder,
tabAction: "move",
targetTabIndex: selectedTabIndex,
targetTabName: tabs[selectedTabIndex as number],
}),
);
}}
>
Move to Tab
</button>
</div>
)}
</form>
</div>
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
<span class={`w-[200px] py-3 px-6`}></span>
<span class={`w-[16px]`}></span>
<button
type="button"
onClick={() => {
globalThis.location.href = "/language/dashboard";
}}
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
>
Dashboard
</button>
</div>
</div>
</>
);
}

View File

@ -1,61 +0,0 @@
// islands/AsyncVoiceRecorder.ts
import { useRef, useState } from "preact/hooks";
import VoiceRecorder from "./VoiceRecorder.tsx";
import { AudioFile } from "../utils/types.ts";
export function AsyncVoiceRecorder() {
const queueRef = useRef<
{
audioFile: AudioFile;
role: string;
resolve: (value: Blob | null) => void;
}[]
>([]);
const [popup, setPopup] = useState<
{
audioFile: AudioFile;
role: string;
resolve: (value: Blob | null) => void;
} | null
>(null);
// Show the popup and return a Promise for the answer
const record = (
audioFile: AudioFile,
role: string,
): Promise<Blob | null> => {
return new Promise((resolve) => {
const req = { audioFile, role, resolve };
setPopup((current) => {
if (current) {
queueRef.current.push(req);
return current;
}
return req;
});
});
};
// Render the popup if needed
const popupRecorder = popup
? (
<VoiceRecorder
text={popup.audioFile.text}
audio_file={popup.audioFile.audio_file}
role={popup.role}
recordingCallback={(answer) => {
setPopup((current) => {
if (current) {
current.resolve(answer);
}
const next = queueRef.current.shift() ?? null;
return next;
});
}}
/>
)
: null;
return [record, popupRecorder] as const;
}

View File

@ -1,770 +0,0 @@
// islands/BrainStudy.tsx
// Brain study component with locked-set mastery behavior
// Cards must be mastered through all 6 modes before new cards are introduced
import { useEffect, useRef, useState } from "preact/hooks";
import { signal } from "@preact/signals";
import Card from "./Card.tsx";
import { BrainQueueItem } from "../utils/types.ts";
// Import mode utilities (these will be inlined for client-side use)
const QUIZ_MODES = [
"photo-see-bisaya",
"photo-see-english",
"photo-hide-bisaya",
"photo-hide-english",
"blank-see-bisaya",
"blank-see-english",
] as const;
const MODE_LABELS: Record<string, string> = {
"photo-see-bisaya": "Photo, See Bisaya",
"photo-see-english": "Photo, See English",
"photo-hide-bisaya": "Blank, Hide Bisaya",
"photo-hide-english": "Photo, Hide English",
"blank-see-bisaya": "Blank, See Bisaya",
"blank-see-english": "Blank, See English",
};
function getModeDisplayInfo(mode: string) {
const index = QUIZ_MODES.indexOf(mode as typeof QUIZ_MODES[number]);
const safeIndex = index >= 0 ? index : 0;
return {
index: safeIndex + 1,
total: QUIZ_MODES.length,
label: MODE_LABELS[mode] || mode,
isFinal: safeIndex === QUIZ_MODES.length - 1,
};
}
function getNextMode(currentMode: string): string | null {
const index = QUIZ_MODES.indexOf(currentMode as typeof QUIZ_MODES[number]);
if (index >= 0 && index < QUIZ_MODES.length - 1) {
return QUIZ_MODES[index + 1];
}
return null; // Already at final mode
}
interface BrainStudyProps {
userId: string;
role: string;
initialMode: string;
tab_index?: number;
}
interface QueueResponse {
success: boolean;
queue: BrainQueueItem[];
stats: {
totalReviewed: number;
totalCorrect: number;
dueNow: number;
accuracy: number;
reviewOnlyCount?: number;
};
mode: string;
totalInQueue: number;
dueCount: number;
newCount: number;
activeSetCount?: number;
}
interface SessionStats {
reviewed: number;
correctFirstTry: number;
sessionStart: number;
modeAdvances: number; // Track how many cards advanced to next mode
cardsRetired: number; // Cards that completed all 6 modes this session
}
// Extended queue item for client-side tracking
interface ActiveQueueItem extends BrainQueueItem {
// Client-side tracking
clientModeIndex: number; // Current mode being studied (client-side, may differ from server after correct answer)
clientDisplayMode: string; // Current mode for display
hasRetired: boolean; // Has completed all 6 modes
}
const useInputHint = signal(false);
export default function BrainStudy(
{ userId, role, initialMode, tab_index }: BrainStudyProps,
) {
const [loading, setLoading] = useState(true);
const [hasStarted, setHasStarted] = useState(false);
// Active set: the locked set of cards being mastered
const [activeSet, setActiveSet] = useState<ActiveQueueItem[]>([]);
// Pool of available new cards (from server) that haven't been added to active set yet
const [newCardPool, setNewCardPool] = useState<BrainQueueItem[]>([]);
// Current position in active set rotation
const [currentIndex, setCurrentIndex] = useState(0);
const [sessionStats, setSessionStats] = useState<SessionStats>({
reviewed: 0,
correctFirstTry: 0,
sessionStart: Date.now(),
modeAdvances: 0,
cardsRetired: 0,
});
const [overallStats, setOverallStats] = useState({
totalReviewed: 0,
totalCorrect: 0,
dueNow: 0,
accuracy: 0,
reviewOnlyCount: 0,
});
const [isComplete, setIsComplete] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isFirstAttempt, setIsFirstAttempt] = useState(true);
const [cardKey, setCardKey] = useState(0);
// Card state
const [cardInputParams, setCardInputParams] = useState({
setFullAuto: (_auto: boolean) => {},
runFullAuto: false,
setTogglePause: (_fn: () => void) => {},
setIsPausedDisplay: (_paused: boolean) => {},
questionPhrase: "",
answerPhrase: "",
nextButton: () => {},
audio1: "",
audio2: "",
onHelpUsed: () => {},
flipped: false,
});
const [backgroundUrl, setBackgroundUrl] = useState("");
// Track first attempt per question
const firstAttemptRef = useRef(true);
// Maximum active set size
const MAX_ACTIVE_SET_SIZE = 10;
// Fetch queue on mount
useEffect(() => {
fetchQueue();
}, []);
// Convert server queue items to active queue items with client-side tracking
const initializeActiveSet = (items: BrainQueueItem[]): ActiveQueueItem[] => {
return items.map((item) => ({
...item,
clientModeIndex: item.mode_index,
clientDisplayMode: item.display_mode,
hasRetired: false,
}));
};
const fetchQueue = async () => {
setLoading(true);
setError(null);
try {
// Request more cards than we need for the active set so we have a pool to draw from
// Include tab_index if provided to filter quiz configs by tab
const tabParam = tab_index !== undefined ? `&tab_index=${tab_index}` : "";
const response = await fetch(
`/api/brain/queue?limit=100&newLimit=100${tabParam}`,
);
if (!response.ok) {
throw new Error("Failed to fetch queue");
}
const data: QueueResponse = await response.json();
if (data.success) {
// Separate the queue into active set (up to MAX_ACTIVE_SET_SIZE) and pool
const allCards = data.queue.filter((q) => !q.is_review_only);
const initialActiveCards = allCards.slice(0, MAX_ACTIVE_SET_SIZE);
const remainingPool = allCards.slice(MAX_ACTIVE_SET_SIZE);
setActiveSet(initializeActiveSet(initialActiveCards));
setNewCardPool(remainingPool);
setOverallStats({
...data.stats,
reviewOnlyCount: data.stats.reviewOnlyCount ?? 0,
});
setCurrentIndex(0);
setIsComplete(allCards.length === 0);
} else {
throw new Error("Failed to build queue");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
};
/**
* Configure card display based on mode
* Each mode determines:
* - What text is shown (questionPhrase)
* - What the user types (answerPhrase)
* - Whether to show the photo (backgroundUrl)
* - Whether to hide certain text (done via questionPhrase)
*
* Both audio tracks always play for every mode.
*/
const loadCard = (item: ActiveQueueItem) => {
const question = item.question_data;
// Use client-side mode tracking for display
const mode = item.clientDisplayMode;
firstAttemptRef.current = true;
setIsFirstAttempt(true);
let questionPhrase = "";
let answerPhrase = "";
let showPhoto = true;
let flipped = false;
// Configure based on mode
switch (mode) {
case "photo-see-bisaya":
// Show photo, display Bisaya text, user types English
questionPhrase = question.text_1; // Bisaya
answerPhrase = question.text_2; // English
showPhoto = true;
flipped = false;
break;
case "photo-see-english":
// Show photo, display English text, user types Bisaya
questionPhrase = question.text_2; // English
answerPhrase = question.text_1; // Bisaya
showPhoto = true;
flipped = true;
break;
case "photo-hide-bisaya":
// Show photo, hide Bisaya (rely on audio), user types Bisaya
questionPhrase = "";
answerPhrase = question.text_2; // Bisaya
showPhoto = false;
flipped = false;
break;
case "photo-hide-english":
// Show photo, hide English, user types English
questionPhrase = "";
answerPhrase = question.text_1; // English
showPhoto = true;
flipped = true;
break;
case "blank-see-bisaya":
// No photo, display Bisaya text, user types English
questionPhrase = question.text_2; // Bisaya
answerPhrase = question.text_1; // English
showPhoto = false;
flipped = true;
break;
case "blank-see-english":
// No photo, display English text, user types Bisaya
questionPhrase = question.text_1; // English
answerPhrase = question.text_2; // Bisaya
showPhoto = false;
flipped = false;
break;
default:
// Fallback to photo-see-english behavior
questionPhrase = question.text_2;
answerPhrase = question.text_1;
showPhoto = true;
}
setCardInputParams({
setFullAuto: () => {},
runFullAuto: false,
setTogglePause: () => {},
setIsPausedDisplay: () => {},
questionPhrase,
answerPhrase,
nextButton: () => handleNextCard(true),
// Both audio tracks always provided for every mode
audio1: question.audio_file_1,
audio2: "",
onHelpUsed: handleWrongAttempt,
flipped,
});
// Set background only if mode requires photo
setBackgroundUrl(showPhoto ? (question.background_image_data || "") : "");
setCardKey((prev) => prev + 1);
};
// Load the current card whenever the active set or index changes
useEffect(() => {
if (!hasStarted) return;
if (!activeSet.length) return;
if (isComplete) return;
// Wrap around if we've gone past the end (for rotation)
const safeIndex = currentIndex % activeSet.length;
if (safeIndex !== currentIndex) {
setCurrentIndex(safeIndex);
return;
}
loadCard(activeSet[safeIndex]);
}, [activeSet, currentIndex, isComplete, hasStarted]);
const recordProgress = async (
wasCorrectFirstTry: boolean,
): Promise<{ modeAdvanced: boolean; isReviewOnly: boolean } | null> => {
const safeIndex = currentIndex % activeSet.length;
const currentItem = activeSet[safeIndex];
if (!currentItem) return null;
try {
const response = await fetch("/api/brain/progress", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
questionId: currentItem.question_id,
quizId: currentItem.quiz_id,
wasCorrectFirstTry,
}),
});
if (response.ok) {
const data = await response.json();
return {
modeAdvanced: data.modeAdvanced ?? false,
isReviewOnly: data.isReviewOnly ?? false,
};
}
} catch (err) {
console.error("Failed to record progress:", err);
}
return null;
};
// Pull cards from the pool to fill the active set
const refillActiveSet = () => {
if (newCardPool.length === 0) return;
const currentActiveCount =
activeSet.filter((item) => !item.hasRetired).length;
const slotsAvailable = MAX_ACTIVE_SET_SIZE - currentActiveCount;
if (slotsAvailable > 0 && newCardPool.length > 0) {
const cardsToAdd = newCardPool.slice(0, slotsAvailable);
const remainingPool = newCardPool.slice(slotsAvailable);
setNewCardPool(remainingPool);
setActiveSet((prev) => {
// Filter out retired cards and add new ones
const activeCards = prev.filter((item) => !item.hasRetired);
const newActiveItems = initializeActiveSet(cardsToAdd);
return [...activeCards, ...newActiveItems];
});
}
};
const handleNextCard = async (wasCorrect: boolean) => {
// Only count first attempt
const wasFirstAttempt = firstAttemptRef.current;
firstAttemptRef.current = false;
const safeIndex = currentIndex % activeSet.length;
const currentItem = activeSet[safeIndex];
if (!currentItem) {
setIsComplete(true);
return;
}
let cardRetired = false;
if (wasFirstAttempt) {
// Record progress to server
await recordProgress(wasCorrect);
// Update session stats
setSessionStats((prev) => ({
...prev,
reviewed: prev.reviewed + 1,
correctFirstTry: prev.correctFirstTry + (wasCorrect ? 1 : 0),
}));
if (wasCorrect) {
// Correct on first try: advance mode
const nextMode = getNextMode(currentItem.clientDisplayMode);
if (nextMode !== null) {
// Advance to next mode in the progression
setSessionStats((prev) => ({
...prev,
modeAdvances: prev.modeAdvances + 1,
}));
// Update the card's client-side mode
setActiveSet((prev) => {
const updated = [...prev];
const idx = safeIndex;
if (updated[idx]) {
updated[idx] = {
...updated[idx],
clientModeIndex: updated[idx].clientModeIndex + 1,
clientDisplayMode: nextMode,
};
}
return updated;
});
} else {
// Already at final mode and correct on first try - card is RETIRED
cardRetired = true;
setSessionStats((prev) => ({
...prev,
cardsRetired: prev.cardsRetired + 1,
}));
// Mark the card as retired and remove from active set
setActiveSet((prev) => {
const updated = prev.filter((_, idx) => idx !== safeIndex);
return updated;
});
}
}
// If not correct on first try: card stays in rotation at same mode (no state change needed)
}
// Move to next card in the rotation
setActiveSet((prevActiveSet) => {
if (prevActiveSet.length === 0) {
// All cards retired, try to refill
if (newCardPool.length > 0) {
// Will be handled by refillActiveSet
return prevActiveSet;
}
setIsComplete(true);
return prevActiveSet;
}
return prevActiveSet;
});
// Adjust index for next card
setCurrentIndex((prevIndex) => {
// Get current active set length (accounting for possible removal)
const currentSetLength = cardRetired
? activeSet.length - 1
: activeSet.length;
if (currentSetLength === 0) {
return 0;
}
// Move to next card, wrapping around
const nextIndex = cardRetired
? prevIndex % currentSetLength // Stay at same index (next card shifted into position)
: (prevIndex + 1) % currentSetLength;
return nextIndex;
});
// Check if we need to refill the active set
if (cardRetired) {
// Use setTimeout to ensure state has updated
setTimeout(() => {
refillActiveSet();
}, 0);
}
// Check if session is complete
setTimeout(() => {
setActiveSet((currentActiveSet) => {
if (currentActiveSet.length === 0 && newCardPool.length === 0) {
setIsComplete(true);
}
return currentActiveSet;
});
}, 100);
};
const handleWrongAttempt = () => {
// Mark that this is no longer a first attempt
if (firstAttemptRef.current) {
firstAttemptRef.current = false;
setIsFirstAttempt(false);
}
};
const handleReturnToDashboard = () => {
const baseUrl = "/language/dashboard";
const returnUrl = tab_index !== undefined
? `${baseUrl}?tab_index=${tab_index}`
: baseUrl;
globalThis.location.href = returnUrl;
};
const handleRestartSession = async () => {
setLoading(true);
setError(null);
try {
// Reset all quiz progress on server (back to first study mode)
const response = await fetch("/api/brain/reset-session", {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to reset session");
}
// Reset local state
setSessionStats({
reviewed: 0,
correctFirstTry: 0,
sessionStart: Date.now(),
modeAdvances: 0,
cardsRetired: 0,
});
setHasStarted(false);
setActiveSet([]);
setNewCardPool([]);
setCurrentIndex(0);
setIsComplete(false);
await fetchQueue();
} catch (err) {
setError(
err instanceof Error
? err.message
: "Failed to reset session. Please try again.",
);
} finally {
setLoading(false);
}
};
// Loading state
if (loading) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
<div className="animate-spin w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full mx-auto">
</div>
<p className="mt-4 theme-text">Loading study queue...</p>
</div>
);
}
// Error state
if (error) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Error</h2>
<p className="text-red-500 mb-4">{error}</p>
<button
onClick={fetchQueue}
className="bg-blue-500 text-white px-6 py-2 rounded-full hover:bg-blue-600"
>
Try Again
</button>
</div>
);
}
// Complete state
if (isComplete) {
const accuracy = sessionStats.reviewed > 0
? Math.round((sessionStats.correctFirstTry / sessionStats.reviewed) * 100)
: 0;
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">
Session Complete!
</h2>
<div className="space-y-4 mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-lg">
<p className="text-3xl font-bold text-green-600 dark:text-green-400">
{sessionStats.reviewed}
</p>
<p className="text-sm theme-text">Cards Reviewed</p>
</div>
<div className="bg-blue-100 dark:bg-blue-900 p-4 rounded-lg">
<p className="text-3xl font-bold text-blue-600 dark:text-blue-400">
{accuracy}%
</p>
<p className="text-sm theme-text">First-Try Accuracy</p>
</div>
<div className="bg-purple-100 dark:bg-purple-900 p-4 rounded-lg">
<p className="text-xl font-bold text-purple-600 dark:text-purple-400">
{sessionStats.correctFirstTry} / {sessionStats.reviewed}
</p>
<p className="text-sm theme-text">Correct on First Try</p>
</div>
{sessionStats.modeAdvances > 0 && (
<div className="bg-yellow-100 dark:bg-yellow-900 p-4 rounded-lg">
<p className="text-xl font-bold text-yellow-600 dark:text-yellow-400">
{sessionStats.modeAdvances}
</p>
<p className="text-sm theme-text">Mode Advances</p>
</div>
)}
{sessionStats.cardsRetired > 0 && (
<div className="bg-emerald-100 dark:bg-emerald-900 p-4 rounded-lg">
<p className="text-xl font-bold text-emerald-600 dark:text-emerald-400">
{sessionStats.cardsRetired}
</p>
<p className="text-sm theme-text">
Cards Fully Mastered (All 6 Modes)
</p>
</div>
)}
</div>
<div className="space-y-3">
<button
onClick={handleRestartSession}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
>
Start New Session
</button>
<button
onClick={handleReturnToDashboard}
className="w-full bg-gray-500 text-white px-6 py-3 rounded-full hover:bg-gray-600 font-semibold"
>
Return to Dashboard
</button>
</div>
</div>
);
}
if (!hasStarted) {
return (
<div className="theme-container p-8 rounded-xl shadow-2xl max-w-md w-full text-center">
<h2 className="text-2xl font-bold mb-4 theme-text">Brain Study</h2>
<p className="theme-text mb-6">
Press Start to begin. This enables audio playback for the first card.
</p>
<button
type="button"
onClick={() => {
try {
const Webkit = globalThis as unknown as {
webkitAudioContext?: typeof AudioContext;
};
const Ctx = globalThis.AudioContext || Webkit.webkitAudioContext;
if (Ctx) {
const ctx = new Ctx();
ctx.resume?.();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
gain.gain.value = 0;
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.01);
setTimeout(() => ctx.close?.(), 50);
}
} catch (_err) {
void _err;
}
setHasStarted(true);
}}
className="w-full bg-blue-500 text-white px-6 py-3 rounded-full hover:bg-blue-600 font-semibold"
>
Start
</button>
</div>
);
}
// Study state
const safeIndex = activeSet.length > 0 ? currentIndex % activeSet.length : 0;
const currentItem = activeSet[safeIndex];
// Show progress as "X cards remaining in active set"
const progress = `${safeIndex + 1} / ${activeSet.length}`;
const modeInfo = getModeDisplayInfo(
currentItem?.clientDisplayMode || QUIZ_MODES[0],
);
return (
<>
<div className="theme-container p-2 sm:p-3 md:p-2 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip">
{/* Header with progress and mode indicator */}
<div className="flex flex-col gap-2 mb-4">
{/* Mode indicator row */}
{/* Progress row */}
<div className="flex items-center gap-4">
<div className="flex-[0.7]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-medium px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300">
Step {modeInfo.index}/{modeInfo.total}
</span>
</div>
{currentItem?.is_review_only && (
<span className="text-xs font-medium px-2 py-1 rounded-full bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300">
Review
</span>
)}
</div>
</div>
<div className="flex-[0.7]">
<div className="text-sm theme-text">
<span className="font-semibold">{progress}</span>
<span className="ml-2 text-gray-500">
({currentItem?.is_new ? "New" : "Old"})
</span>
</div>
</div>
<div className="flex-[0.6]">
<button
type="button"
onClick={handleReturnToDashboard}
className="px-4 py-2 rounded-full theme-button font-semibold shadow-lg"
>
Dashboard
</button>
</div>
</div>
</div>
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
>
{/* Card */}
<Card
key={cardKey}
cardInputParams={cardInputParams}
backgroundUrl={backgroundUrl}
hideText={false}
useInputHint={useInputHint}
/>
{/* Skip button */}
<div className="mt-4 flex justify-center">
<button
onClick={() => handleNextCard(false)}
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label="Next question"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
</div>
</div>
</>
);
}

View File

@ -1,856 +0,0 @@
// components/Card.tsx
import { useEffect, useRef, useState } from "preact/hooks";
import { TargetedKeyboardEvent } from "preact";
import { Signal } from "@preact/signals";
import { decodeData } from "../utils/urlEncoder.ts";
//import { useSpeechSynthesis } from 'react-speech-kit';
interface CardProps {
cardInputParams: {
setFullAuto: (auto: boolean) => void;
runFullAuto: boolean;
setTogglePause: (fn: () => void) => void;
setIsPausedDisplay: (paused: boolean) => void;
questionPhrase: string;
answerPhrase: string;
nextButton: () => void;
audio1: string;
audio2: string;
onHelpUsed: () => void;
flipped: boolean;
};
backgroundUrl: string;
hideText: boolean;
useInputHint: Signal<boolean>;
}
export default function Card(
{ cardInputParams, backgroundUrl, hideText, useInputHint }: CardProps,
) {
// const [currentBackgroundImage, setCurrentBackgroundImage] = useState(() => {
// //console.log("imageData");
// // if (backgroundUrl) {
// // console.log(backgroundUrl.substring(0, 100));
// // }
// return backgroundUrl;
// });
const audioRef_happy = useRef<HTMLAudioElement>(null);
const audioRef_fail = useRef<HTMLAudioElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState("");
const [message, setMessage] = useState("");
const [helpClick, setHelpClick] = useState(false);
const [fullAutoInterval, setFullAutoInterval] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const isPausedRef = useRef(false);
const audioRef1 = useRef<HTMLAudioElement>(null);
const audioRef2 = useRef<HTMLAudioElement>(null);
const [initialAudioUrl1, setInitialAudioUrl1] = useState<string | null>(null);
const [initialAudioUrl2, setInitialAudioUrl2] = useState<string | null>(null);
const playSequenceId = useRef(0);
const [playSequenceComplete, setPlaySequenceComplete] = useState(true);
const [hint, setHintDisplay] = useState("");
// Register toggle pause function
useEffect(() => {
cardInputParams.setTogglePause(() => {
setIsPaused((prev) => {
const newVal = !prev;
isPausedRef.current = newVal;
cardInputParams.setIsPausedDisplay(newVal);
return newVal;
});
});
}, [cardInputParams]);
// Fetch audio files from API when audio1/audio2 props change
useEffect(() => {
let url1: string | null = null;
let url2: string | null = null;
if (cardInputParams.audio1 || cardInputParams.audio2) {
setPlaySequenceComplete(false);
}
const fetchAudio = async (
audioFile: string,
setter: (url: string | null) => void,
) => {
if (!audioFile) {
setter(null);
return null;
}
try {
const response = await fetch(
`/api/audio-blob/${encodeURIComponent(audioFile)}`,
{ credentials: "include" },
);
if (!response.ok) {
console.error("Failed to fetch audio file:", response.statusText);
return null;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setter(url);
return url;
} catch (err) {
console.error("Error fetching audio file:", err);
return null;
}
};
const loadAudios = async () => {
url1 = cardInputParams.audio1
? await fetchAudio(cardInputParams.audio1, setInitialAudioUrl1)
: "";
url2 = cardInputParams.audio2
? await fetchAudio(cardInputParams.audio2, setInitialAudioUrl2)
: "";
};
loadAudios();
// Cleanup blob URLs on unmount or when audio props change
return () => {
if (url1) URL.revokeObjectURL(url1);
if (url2) URL.revokeObjectURL(url2);
};
}, [cardInputParams.audio1, cardInputParams.audio2]);
const stopAudio = (audio: HTMLAudioElement | null) => {
if (!audio) return;
try {
audio.pause();
audio.currentTime = 0;
} catch (_err) {
// Ignore
}
};
const waitForCanPlayThrough = (audio: HTMLAudioElement) => {
if (audio.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
return Promise.resolve();
}
return new Promise<void>((resolve) => {
const onReady = () => {
audio.removeEventListener("canplaythrough", onReady);
resolve();
};
audio.addEventListener("canplaythrough", onReady);
});
};
const playAndWaitDone = async (
audio: HTMLAudioElement,
requestId: number,
): Promise<boolean> => {
await waitForCanPlayThrough(audio);
if (requestId !== playSequenceId.current) return false;
audio.currentTime = 0;
try {
await audio.play();
} catch (err: unknown) {
const errName = (err && typeof err === "object" && "name" in err &&
typeof (err as { name: unknown }).name === "string")
? (err as { name: string }).name
: "";
if (errName === "NotAllowedError") {
console.log("Audio autoplay blocked");
return true;
}
console.error("Audio play error:", err);
return true;
}
await new Promise<void>((resolve) => {
const onDone = () => {
cleanup();
resolve();
};
const cleanup = () => {
audio.removeEventListener("ended", onDone);
audio.removeEventListener("pause", onDone);
audio.removeEventListener("error", onDone);
};
audio.addEventListener("ended", onDone);
audio.addEventListener("pause", onDone);
audio.addEventListener("error", onDone);
});
return true;
};
const playSequence = async () => {
const requestId = ++playSequenceId.current;
stopAudio(audioRef1.current);
stopAudio(audioRef2.current);
const audio1 = initialAudioUrl1 ? audioRef1.current : null;
const audio2 = initialAudioUrl2 ? audioRef2.current : null;
if (audio1) {
const shouldContinue = await playAndWaitDone(audio1, requestId);
if (!shouldContinue || requestId !== playSequenceId.current) {
return;
}
}
if (audio2) {
await playAndWaitDone(audio2, requestId);
}
if (cardInputParams.runFullAuto) {
let firstCouple = 0;
const answerLength = Math.max(
1,
(cardInputParams.answerPhrase ?? "").length,
);
const autoIntervalMs = Math.max(1, Math.floor(3000 / answerLength));
const autoInterval = setInterval(() => {
// Skip if paused
if (!isPausedRef.current) {
if (firstCouple > 3) {
let canClickHelp = false;
setHelpClick((prev) => {
//if (!prev){
canClickHelp = prev;
//return true;
//}
return prev;
});
if (!canClickHelp) {
let inputNow = "";
setInputValue((prev) => {
inputNow = prev;
return prev;
});
//console.log("auto!!!",cardInputParams.answerPhrase, inputNow);
if (inputNow !== cardInputParams.answerPhrase) {
helpNext(inputNow);
} else {
clearInterval(autoInterval);
return;
}
}
} else {
firstCouple += 1;
}
}
}, autoIntervalMs);
setFullAutoInterval(autoInterval);
}
if (requestId === playSequenceId.current) {
setPlaySequenceComplete(true);
}
};
// Auto-play audio1 when loaded, then sequence audio2 after audio1 completes
useEffect(() => {
if (!initialAudioUrl1 && !initialAudioUrl2) return;
playSequence();
return () => {
playSequenceId.current += 1;
stopAudio(audioRef1.current);
stopAudio(audioRef2.current);
setPlaySequenceComplete(true);
};
}, [initialAudioUrl1, initialAudioUrl2]);
const effectSetFullAuto = () => {
setInputValue(defaultInputValue);
return 0;
};
//let lastInputValue = "";
//let lastInputValueLength = -1;
const defaultInputValue = "";
const setHint = (quizInput: string, specialInput = "") => {
const asciitestWord = toAscii(cardInputParams.answerPhrase).toLowerCase();
const hintChars = new Array(asciitestWord.length);
let answerInput = "";
if (specialInput === "") {
if (quizInput) {
answerInput = toAscii(quizInput.trim().toLowerCase());
}
} else {
answerInput = specialInput.toLowerCase();
}
for (let i = 0; i < asciitestWord.length; ++i) {
const testCh = asciitestWord[i];
if (/\s/.test(testCh)) {
hintChars[i] = " ";
} else {
if (answerInput && answerInput.length > i) {
const answerCh = answerInput[i];
if (!/[a-zA-Z0-9]/.test(answerCh) || answerCh === testCh) {
hintChars[i] = cardInputParams.answerPhrase[i];
} else {
hintChars[i] = "-";
}
} else {
if (!/[a-zA-Z0-9]/.test(testCh)) {
hintChars[i] = cardInputParams.answerPhrase[i];
} else {
hintChars[i] = "-";
}
}
}
}
return hintChars.join("");
};
// const loadVoices = () => {
// const voices = window.speechSynthesis.getVoices();
// console.log("voices:")
// voices.forEach(voice => {
// console.log("-> " + voice.name + " " + voice.lang);
// });
// }
//const { speak } = useSpeechSynthesis();
const speak = (text: any) => {
//loadVoices();
// console.log("speech start");
// const utterance = new SpeechSynthesisUtterance("hello world");
// //utterance.lang = 'fil-PH'; // Set language to Filipino
// window.speechSynthesis.speak(utterance);
// console.log("speech done");
};
// Play the initial audio_file_1 automatically when the component mounts
// useEffect(() => {
// if (audio_file.startsWith("http") && initialAudioRef.current) {
// // Attempt to play programmatically for better browser support
// initialAudioRef.current.play().catch(() => {
// // Autoplay might be blocked, user must interact
// });
// }
// }, [audio_file]);
useEffect(() => {
//console.log("intitial url");
//const decoded = decodeKvURL(initialQuizData.questions[0].background_image_data);
//console.log(backgroundImage.substring(0,100));
//setCurrentBackgroundImage(backgroundUrl);
if (inputRef.current) {
inputRef.current.focus();
}
//console.log("background ", backgroundImage);
setHintDisplay(setHint(""));
}, [cardInputParams]);
const handleInputChange = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
setInputValue((prev) => {
// console.log("keypress",
// e.currentTarget.value);
return e.currentTarget.value.trim();
});
};
const waitForAudioEnd = (audio: HTMLAudioElement): Promise<void> => {
return new Promise((resolve) => {
const onDone = () => {
audio.removeEventListener("ended", onDone);
audio.removeEventListener("pause", onDone);
audio.removeEventListener("error", onDone);
resolve();
};
audio.addEventListener("ended", onDone);
audio.addEventListener("pause", onDone);
audio.addEventListener("error", onDone);
});
};
const playFinalAudio = async () => {
if (cardInputParams.audio1 && audioRef1.current) {
try {
await audioRef1.current.play();
await waitForAudioEnd(audioRef1.current);
} catch {
// Ignore play errors
}
}
if (audioRef_happy.current) {
try {
audioRef_happy.current.volume = 0.6;
await audioRef_happy.current.play();
await waitForAudioEnd(audioRef_happy.current);
} catch {
// Ignore play errors
}
}
setTimeout(() => {
setInputValue("");
setMessage("");
cardInputParams.nextButton();
setHelpClick(false);
}, 1000);
};
const checkAnswer = (value: string) => {
const processedInput: boolean = handleValidSubmit(value);
if (processedInput) { //}.toLowerCase() === answerPhrase.toLowerCase()) {
setMessage("Correct! Great job!");
playFinalAudio();
} else {
if (audioRef_fail.current) {
audioRef_fail.current.volume = 0.6;
audioRef_fail.current.play().catch(() => {});
}
setMessage("");
}
};
const handleKeyPress = (e: TargetedKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
if (e.shiftKey) {
e.preventDefault();
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
cardInputParams.onHelpUsed(); // Mark as not first attempt
setHelpClick(true);
helpNext(inputValue);
}
} else {
const timValue = e.currentTarget.value.trim();
//console.log(timValue);
setInputValue(timValue);
checkAnswer(timValue);
}
}
};
const handleValidSubmit = (quizInput: string) => {
let answerInput = "";
if (quizInput) {
answerInput = toAscii(quizInput.trim());
//console.log("ascii",answerInput);
} else {
return false;
}
const asciitestWord = toAscii(cardInputParams.answerPhrase);
if (asciitestWord[0] === "?") {
if (answerInput.length === 0) {
answerInput = "?";
} else if (answerInput[0] !== "?") {
answerInput = "?" + answerInput;
}
}
if (asciitestWord[0] === "!") {
if (answerInput.length === 0) {
answerInput = "!";
} else if (answerInput[0] !== "!") {
answerInput = "!" + answerInput;
}
}
if (
asciitestWord[asciitestWord.length - 1] === "?" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "?";
}
if (
asciitestWord[asciitestWord.length - 1] === "!" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "!";
}
if (
asciitestWord[asciitestWord.length - 1] === "." &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + ".";
}
if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) {
setInputValue(answerInput);
setHintDisplay(setHint(answerInput));
return true;
}
let outputStr = "";
let i = 0;
for (; i < cardInputParams.answerPhrase.length; ++i) {
const asciiCh = asciitestWord[i];
if (!/[a-zA-Z0-9]/.test(asciiCh)) {
outputStr += cardInputParams.answerPhrase[i];
continue;
}
if (i >= answerInput.length) {
break;
}
const ch = answerInput[i];
if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) {
break;
}
outputStr += cardInputParams.answerPhrase[i];
}
if (outputStr === cardInputParams.answerPhrase) {
setInputValue(outputStr);
setHintDisplay(setHint(outputStr));
return true;
}
setHintDisplay(setHint(outputStr));
setInputValue((prev) => {
//console.log("set input end",prev,outputStr);
return outputStr;
});
return false;
};
const helpNext = (value: string) => {
const processedInput: string = handleValidHelp(value.trim());
if (
trimJunk(toAscii(processedInput)).toLowerCase() ===
trimJunk(toAscii(cardInputParams.answerPhrase)).toLowerCase()
) {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
}
return 0;
});
setMessage("Correct! Great job!");
playFinalAudio();
} else {
setMessage("");
setHelpClick(false);
}
};
const handleValidHelp = (quizInput: string) => {
let answerInput = "";
if (quizInput) {
answerInput = toAscii(quizInput.trim());
}
const asciitestWord = toAscii(cardInputParams.answerPhrase);
if (asciitestWord[0] === "?") {
if (answerInput.length === 0) {
answerInput = "?";
} else if (answerInput[0] !== "?") {
answerInput = "?" + answerInput;
}
}
if (asciitestWord[0] === "!") {
if (answerInput.length === 0) {
answerInput = "!";
} else if (answerInput[0] !== "!") {
answerInput = "!" + answerInput;
}
}
if (
asciitestWord[asciitestWord.length - 1] === "?" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "?";
}
if (
asciitestWord[asciitestWord.length - 1] === "!" &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + "!";
}
if (
asciitestWord[asciitestWord.length - 1] === "." &&
(asciitestWord.length - 1) === answerInput.length
) {
answerInput = answerInput + ".";
}
if (asciitestWord && trimJunk(answerInput) === trimJunk(asciitestWord)) {
setInputValue(answerInput);
setHintDisplay(setHint(answerInput));
return answerInput;
}
let outputStr = "";
let i = 0;
for (; i < cardInputParams.answerPhrase.length; ++i) {
const asciiCh = asciitestWord[i];
if (!/[a-zA-Z0-9]/.test(asciiCh)) {
outputStr += cardInputParams.answerPhrase[i];
continue;
}
if (i >= answerInput.length) {
break;
}
const ch = answerInput[i];
if (ch.toLowerCase() !== asciitestWord[i].toLowerCase()) {
break;
}
outputStr += cardInputParams.answerPhrase[i];
}
if (outputStr === cardInputParams.answerPhrase) {
setInputValue(outputStr);
setHintDisplay(setHint(outputStr));
return outputStr;
}
if (asciitestWord.length > outputStr.length) {
outputStr = asciitestWord.substring(0, outputStr.length + 1);
if (outputStr[outputStr.length - 1] === " ") {
outputStr = asciitestWord.substring(0, outputStr.length + 1);
}
setHintDisplay(setHint(quizInput, outputStr));
setInputValue(outputStr);
}
return outputStr;
};
// const divStyle = {
// width: "320px",
// height: "auto",
// backgroundImage: `url(${currentBackgroundImage})`,
// backgroundSize: "cover",
// backgroundPosition: "center",
// //
const questionPhrase = cardInputParams.questionPhrase || "";
const trimmedQuestionPhrase = questionPhrase.trim();
const shouldShowPlaceholder = hideText || trimmedQuestionPhrase === "";
const answerPhraseLength = cardInputParams.answerPhrase
? cardInputParams.answerPhrase.length
: 0;
const placeholderCharCount = Math.min(
Math.max(questionPhrase.length, answerPhraseLength, 20),
120,
);
const placeholderText = "\u00A0".repeat(placeholderCharCount);
if (!shouldShowPlaceholder && !questionPhrase) {
return null;
}
if (cardInputParams.answerPhrase) {
return (
<div
onClick={() => {
if (inputRef.current) {
inputRef.current.focus();
}
}}
>
<div
style={{
width: "320px",
height: "320px",
backgroundImage: `${
backgroundUrl
? backgroundUrl.startsWith("/api")
? "url(" + backgroundUrl + ")"
: backgroundUrl.startsWith("http")
? "url(" + CSS.escape(decodeData(backgroundUrl)) + ")"
: backgroundUrl.length > 500
? "url(" + backgroundUrl + ")"
: ""
: ""
}`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
className="p-4 rounded-lg theme-card w-80 button-padding"
>
<div className="flex flex-col h-full">
<div className="text-center">
{shouldShowPlaceholder
? (
<span aria-hidden="true" className="select-none">
</span>
)
: (
<p className="strokeme theme-question-text text-2xl">
{cardInputParams.questionPhrase}
</p>
)}
</div>
<div className="absolute bottom-[-20px] left-0 right-0 px-4 space-y-2">
<div className="text-center">
{useInputHint.value &&
(
<p className="strokeme theme-question-text">
{cardInputParams.answerPhrase ? hint : ""}
</p>
)}
</div>
<div>
<div hidden>
{initialAudioUrl1 &&
(
<audio
ref={audioRef1}
src={initialAudioUrl1}
preload="auto"
controls={false}
/>
)}
{initialAudioUrl2 &&
(
<audio
ref={audioRef2}
src={initialAudioUrl2}
preload="auto"
controls={false}
/>
)}
</div>
{cardInputParams.answerPhrase &&
(
<input
type="text"
id="input1"
name="input1"
value={inputValue}
onChange={handleInputChange}
onKeyPress={(
e: TargetedKeyboardEvent<HTMLInputElement>,
) => {
if (!helpClick) {
handleKeyPress(e);
}
}}
ref={inputRef}
placeholder={cardInputParams.flipped
? "Type study language"
: "Type meaning language"}
className="theme-card-input strokeme w-full px-2 -pt-1 py-2 border rounded-md focus:outline-none focus:ring-2"
/>
)}
</div>
{cardInputParams.answerPhrase &&
(
<div className="flex pt-0 pb-[30px]">
<span className="w-[30px]"></span>
<button
onClick={() => {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
cardInputParams.onHelpUsed(); // Mark as not first attempt
setHelpClick(true);
helpNext(inputValue);
}
}}
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
>
Help
</button>
<span className="w-[65px]"></span>
<button
onClick={() => {
setFullAutoInterval((prev) => {
if (prev) {
clearInterval(prev);
cardInputParams.setFullAuto(false);
}
return 0;
});
if (!helpClick) {
checkAnswer(inputValue);
}
}}
className="w-[100px] theme-card-button py-2 px-4 rounded-md transition duration-300"
>
Enter
</button>
</div>
)}
</div>
</div>
</div>
<audio
ref={audioRef_happy}
src="/116779__domrodrig-bell-happy-trim.mp3"
/>
<audio ref={audioRef_fail} src="/342756__rhodesmas__failure-01.mp3" />
</div>
);
} else {
return null;
}
}
function trimJunk(inputStr: string) {
if (!inputStr) {
return "";
}
let i = inputStr.length - 1;
while (i > 0 && !/[a-zA-Z0-9]/.test(inputStr[i])) {
--i;
}
return inputStr.substring(0, i + 1).toLowerCase();
}
function toAscii(input: string) {
if (!input) return "";
return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
}

View File

@ -1,229 +0,0 @@
// islands/QuizConfigure.tsx
import { useEffect, useState } from "preact/hooks";
import { QuizConfig } from "../utils/types.ts";
import { User } from "../db/interfaces.ts";
export interface QuizConfigProps {
quizConfig: QuizConfig;
// kvObj: KvObj<QuizConfig>;
quizOrder: { index: number; slots: string[] };
quizMode: string;
}
export default function QuizConfigure(
{ quizConfig, quizOrder, quizMode }: QuizConfigProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [thisQuizConfig, setQuizConfig] = useState<QuizConfig>(quizConfig);
const [orderedQuizStr, setOrderQuizStr] = useState<string>(
JSON.stringify(thisQuizConfig),
);
const [thisQuizConfigStr, setQuizConfigStr] = useState<string>(
JSON.stringify({
theUpdate: null,
quiz: quizConfig,
//kvObj: kvObj,
quizOrder: quizOrder,
}),
);
const [selectedSlot, setSelectedSlot] = useState(0);
//const [changeMode, setChangeMode] = useState<{ mode: string } | null>(null);
const [nameError, setNameError] = useState(false);
const [thisQuizOder, setThisQuizOrder] = useState(quizOrder);
const [randomOrder, setRandomOrder] = useState(true);
const [modeType, setModeType] = useState(quizMode);
//const [isLoading, setIsLoading] = useState(true);
const [count, setCount] = useState(0);
const handleClick = () => {
setCount((prev) => {
const newCount = prev + 1;
return newCount;
});
};
// const handleQuizNameChange = (e: Event) => {
// const target = e.target as HTMLInputElement;
// if (!target.value) {
// setNameError(true);
// } else {
// thisQuizConfig.quiz_name = target.value;
// setQuizConfig(thisQuizConfig);
// setChangeMode((prev) => {
// if (!prev) {
// return { mode: "quiz" };
// }
//
//
//
// return prev;
// });
// }
// setQuizConfigStr(
// JSON.stringify({
// theUpdate: changeMode?.mode,
// quiz: thisQuizConfig,
// kvObj: kvObj,
// quizOrder: thisQuizOder,
// }),
// );
// };
const handleQuizModeChange = (e: Event) => {
const target = e.target as HTMLSelectElement;
// setQuizConfig((prev) => {
// if (!prev) return null;
// const updated = { ...prev, quiz_mode: target.value };
// //saveQuizConfig(updated); // Save the updated config
// return updated;
// });
};
if (!thisQuizConfig) {
return <div>Error: Unable to load quiz configuration</div>;
}
function handleOwnerChange(
event: Event,
selectedUser: User | undefined,
): void {
if (selectedUser) {
//("selected ", selectedUser.id);
thisQuizConfig.owner_id = selectedUser.id;
setQuizConfig(thisQuizConfig);
//console.log("thisQuizConfig ", thisQuizConfig.owner_id);
}
//console.log("on user change ", JSON.stringify(event));
//throw new Error("Function not implemented.");
}
useEffect(() => {
//console.log("setting mode!!",quizConfig.quiz_mode)
if (quizConfig.quiz_mode) {
setModeType(quizConfig.quiz_mode);
}
}, []);
return (
<>
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
>
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
<div className="flex space-x-2 pb-[10px]">
<form
action="/language/quizzing"
method="POST"
//onSubmit={handleLaunchQuiz}
className="w-[300px] mx-0 px-0"
>
<input
type="hidden"
name="quizConfigData"
value={thisQuizConfigStr}
/>
<input
type="hidden"
name="quizData"
value={orderedQuizStr}
/>
<input
type="hidden"
name="randomValue"
value={`${randomOrder}`}
/>
<input
type="hidden"
name="modeValue"
value={modeType}
/>
<div className="fixed right-10 top-10 ">
<div className="mb-4 flex justify-end items-center">
<div className="flex flex-col items-end">
<button
type="submit"
className={`theme-button px-4 py-2 rounded`}
title="Start"
>
Launch
</button>
<label
htmlFor="randomOrderId"
className={`theme-text mb-1 pt-3 text-sm`}
>
Random Order
</label>
<input
id="randomOrderId"
type="checkbox"
checked={randomOrder}
onChange={(e) =>
setRandomOrder((e.target as HTMLInputElement).checked)}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
</div>
</div>
<div className="flex items-center ">
<label htmlFor="modeSelect" className={`block mr-2 theme-text`}>
Mode
</label>
<select
id="modeSelect"
value={modeType}
onChange={(e) =>
setModeType((e.target as HTMLSelectElement).value)}
className={`theme-select w-36`}
>
<option className="text-black" value="photo-see-bisaya">
Photo, See Bisaya
</option>
<option className="text-black" value="photo-see-english">
Photo, See English
</option>
<option className="text-black" value="photo-hide-bisaya">
Photo, Hide Bisaya
</option>
<option className="text-black" value="photo-hide-english">
Photo, Hide English
</option>
<option className="text-black" value="blank-see-bisaya">
Blank, See Bisaya
</option>
<option className="text-black" value="blank-see-english">
Blank, See English
</option>
</select>
</div>
</form>
</div>
<h2 className={`text-2xl font-bold mb-4 theme-text`}>Study Quiz</h2>
<div className="w-[300px] mx-0 px-0 pb-[12px] mb-[12px]">
<label
className={`font-semibold text-left truncate w-full theme-text`}
>
{"Question count: " + thisQuizConfig.questions.length}
</label>
</div>
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<button
onClick={() => {
localStorage.setItem("lock-route", "/language/dashboard");
globalThis.location.href = "/language/dashboard";
}}
type="button"
class={`w-11/12 max-w-md theme-button font-semibold py-3 px-10 rounded-full shadow-lg pointer-events-auto text-center`}
>
Back to dashboard
</button>
</div>
</>
);
}

View File

@ -1,416 +0,0 @@
// islands/Quizzing.tsx
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
//import { QuizData } from "../utils/database.ts";
import { QuizConfig } from "../utils/types.ts";
import { Signal, signal } from "@preact/signals";
import Card from "./Card.tsx";
import { stringify } from "node:querystring";
interface QuizzingIProps {
initialQuizData: QuizConfig;
runData: string;
}
export const STORAGE_KEY = "QUIZ_STATE";
const useAudio1 = signal(true);
const useAudio2 = signal(true);
const useInputHint = signal(false);
export default function Quizzing({ initialQuizData, runData }: QuizzingIProps) {
const hideText = initialQuizData &&
(initialQuizData.quiz_mode === "photo-hide-english" ||
initialQuizData.quiz_mode === "photo-hide-bisaya");
const [quizData, setQuizData] = useState<QuizConfig>(initialQuizData);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [questionText, setQuextionText] = useState(
quizData ? quizData.questions[0].text_1 : "",
);
const [meaningText, setMeaningText] = useState("");
const [backgroundUrl, setCurrentBackgroundUrl] = useState("");
const [quizCompleted, setQuizCompleted] = useState(false);
const [runFullAuto, setRunFullAuto] = useState(false);
const [isPausedDisplay, setIsPausedDisplay] = useState(false);
const togglePauseRef = useRef<() => void>(() => {});
const [countdown, setCountdown] = useState(3);
const postButtonRef = useRef<HTMLButtonElement>(null);
const [count, setCount] = useState(0);
const [cardKey, setCardKey] = useState(0);
const runFullAutoRef = useRef(runFullAuto);
const meaningTextRef = useRef(meaningText);
const quizDataRef = useRef(quizData);
useEffect(() => {
runFullAutoRef.current = runFullAuto;
}, [runFullAuto]);
useEffect(() => {
meaningTextRef.current = meaningText;
}, [meaningText]);
useEffect(() => {
quizDataRef.current = quizData;
}, [quizData]);
const [cardInputParams, seCardInputParams] = useState({
setFullAuto: setRunFullAuto,
runFullAuto: false,
setTogglePause: (fn: () => void) => {
togglePauseRef.current = fn;
},
setIsPausedDisplay: setIsPausedDisplay,
questionPhrase: quizData ? quizData.questions[0].text_1 : "",
answerPhrase: "",
nextButton: () => {},
audio1: "",
audio2: "",
onHelpUsed: () => {},
flipped: false,
});
const handleClick = () => {
setCount((prev) => {
const newCount = prev + 1;
//eventEmitter.emit("countUpdated", newCount);
return newCount;
});
};
// Ref to track current index synchronously (state updates are async)
const currentIndexRef = useRef(0);
const incTheIndex = (auto: boolean): number => {
let nextIndex = currentIndexRef.current + 1;
if (auto && nextIndex >= initialQuizData.questions.length) {
nextIndex = 0;
}
currentIndexRef.current = nextIndex;
setCurrentQuestionIndex(nextIndex);
return nextIndex;
};
useEffect(() => {
if (quizCompleted && countdown > 0) {
const timer = setTimeout(() => {
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (quizCompleted && countdown === 0) {
if (postButtonRef.current) {
//console.log(runData);
postButtonRef.current.click();
}
//wind ow.location.href = "/language/dashboard";
}
}, [quizCompleted, countdown]);
const handleNextQuestionInternal = useCallback((forceAuto?: boolean) => {
if (forceAuto === true && !runFullAutoRef.current) {
runFullAutoRef.current = true;
setRunFullAuto(true);
}
const thisAuto = typeof forceAuto === "boolean"
? forceAuto
: runFullAutoRef.current;
const quizDataNow = quizDataRef.current;
const meaningTextNow = meaningTextRef.current;
// Determine the next index synchronously
// On first call (meaningText === ""), stay at index 0
// On subsequent calls, increment
const isFirstCall = meaningTextNow === "";
const thisIndex = isFirstCall ? 0 : incTheIndex(thisAuto);
if (thisIndex < quizDataNow.questions.length) {
// Get ALL data from the SAME question object
const thisQuestion = quizDataNow.questions[thisIndex];
const text_1 = thisQuestion.text_1;
const text_2 = thisQuestion.text_2;
const audio1 = useAudio1.value ? thisQuestion.audio_file_1 : "";
const audio2 = useAudio2.value ? thisQuestion.audio_file_2 : "";
const thisDataUrl = thisQuestion.background_image_data || "";
// Update all state together
setQuextionText(text_1);
setMeaningText(text_2);
meaningTextRef.current = text_2;
setCurrentBackgroundUrl(thisDataUrl);
const cardParams = {
setFullAuto: setRunFullAuto,
runFullAuto: thisAuto,
setTogglePause: (fn: () => void) => {
togglePauseRef.current = fn;
},
setIsPausedDisplay: setIsPausedDisplay,
questionPhrase: text_1,
answerPhrase: text_2,
nextButton: handleNextQuestion,
audio1: audio1,
audio2: audio2,
onHelpUsed: () => {},
flipped: initialQuizData.quiz_mode === "photo-hide-english" ||
initialQuizData.quiz_mode === "photo-see-english" ||
initialQuizData.quiz_mode === "blank-see-english",
};
seCardInputParams(cardParams);
setCardKey((prev) => prev + 1); // Force Card remount
handleClick();
} else {
//localStorage.removeItem("QUIZ_STATE");
setQuizCompleted(true);
}
}, [initialQuizData]);
const handleNextQuestion = useCallback(() => {
handleNextQuestionInternal();
}, [handleNextQuestionInternal]);
if (!initialQuizData || !runData) {
return <div>Unable to load quiz</div>;
}
if (!quizData) {
return <div>Loading...</div>;
}
if (quizCompleted) {
return (
<div className="flex flex-col items-center justify-start pt-20 min-h-screen">
<h2 className="text-2xl font-bold mb-4 theme-text">Quiz Completed!</h2>
<div className="relative w-24 h-24">
<svg
className="animate-spin -ml-1 mr-3 h-24 w-24 text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
>
</circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
<span className="absolute inset-0 flex items-center justify-center text-2xl font-bold">
{countdown}
</span>
</div>
<p className="mt-4 theme-text">Redirecting ...</p>
<form
hidden
action="/language/set-quiz"
method="POST"
>
<input
type="hidden"
name="quizConfigData"
value={runData}
/>
<input
type="hidden"
name="quizMode"
value={initialQuizData.quiz_mode}
/>
<button hidden ref={postButtonRef} type="submit">
</button>
</form>
</div>
);
}
//const currentQuestion = quizData.questions[currentQuestionIndex];
//if (quizStarted){
return (
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-clip`}
>
<div hidden>
<button onClick={handleClick}>Increment</button>
<p>Count: {count}</p>
</div>
{meaningText !== ""
? (
<Card
key={cardKey}
cardInputParams={cardInputParams}
backgroundUrl={backgroundUrl}
hideText={hideText}
useInputHint={useInputHint}
/>
)
: (
<div className="text-center p-0">
<p className="theme-text text-xl mb-4">Ready to start quiz</p>
<p className="theme-text text-sm">
Questions: {quizData.questions.length}
</p>
</div>
)}
{meaningText === ""
? (
<div className="mt-0 flex justify-center w-full px-2">
<div className="theme-card w-80 p-4 rounded-lg shadow-md theme-text">
<div className="text-sm opacity-90 mb-3">
Keyboard: shift + return = help
</div>
<div className="space-y-3">
<label className="flex items-center justify-between gap-3">
<span>Play recording 1</span>
<input
type="checkbox"
checked={useAudio1.value}
onChange={(
e,
) => (useAudio1.value = e.currentTarget.checked)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>Play recording 2</span>
<input
type="checkbox"
checked={useAudio2.value}
onChange={(
e,
) => (useAudio2.value = e.currentTarget.checked)}
/>
</label>
<label className="flex items-center justify-between gap-3">
<span>Input hint on</span>
<input
type="checkbox"
checked={useInputHint.value}
onChange={(
e,
) => (useInputHint.value = e.currentTarget.checked)}
/>
</label>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-4">
<button
onClick={() => {
setRunFullAuto(true);
handleNextQuestionInternal(true);
}}
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
title={"Listen Through"}
>
Listen Through
</button>
<button
onClick={handleNextQuestion}
className="bg-blue-500 text-white px-4 py-2 rounded-xl hover:bg-blue-600 w-full whitespace-nowrap"
title={"Start"}
>
Start Quiz
</button>
</div>
</div>
</div>
)
: runFullAuto
? (
<button
type="button"
id="auto-pause-play-button"
onClick={() => togglePauseRef.current()}
style={{ top: "320px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label={isPausedDisplay ? "Resume auto" : "Pause auto"}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
{isPausedDisplay
? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
)
: (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 9v6m4-6v6"
/>
)}
</svg>
</button>
)
: (
<button
type="button"
id="next-question-button"
onClick={handleNextQuestion}
style={{ top: "305px", left: "50%", marginLeft: "-12px" }}
className="bg-opacity-50 fixed bg-blue-500 hover:bg-blue-500 hover:bg-opacity-50 transition duration-300 text-white font-bold p-1 rounded-full"
aria-label="Next question"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
)}
</div>
);
// }
// else{
// return (
// <div className=" bg-opacity-10 backdrop-filter backdrop-blur-lg rounded-xl shadow-2xl p-8 max-w-md w-full">
// <button
// //type="submit"
// onClick={() => {setQuizStarted(true)}}
// className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600 w-full"
// >
// Start
// </button>
// </div>
// );
// }
// Placeholder function for fetching quiz data
// async function fetchQuizData(quiz_id: number): Promise<{}> {
// // Implement the actual data fetching logic here
// // This is just a placeholder
// return { quiz_id: quiz_id, questions: [] };
// }
}

View File

@ -1,39 +0,0 @@
// islands/StudyButton.tsx
import { useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
interface StudyButtonProps {
setCanStudy: any;
}
export default function StudyButton(
{ setCanStudy }: StudyButtonProps,
) {
//const [clicked, setClicked] = useState(true);
const handleClick = () => {
//setClicked(true);
//onClick();
};
const handleStudyClick = (
event: JSX.TargetedMouseEvent<HTMLButtonElement>,
) => {
//console.log("clicked");
if (!setCanStudy.value) {
event.preventDefault();
}
};
return (
<button
type="submit"
form="studyrun"
class={`w-16 h-16 bg-green-600 hover:bg-green-700 text-white font-bold rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 ${
!setCanStudy.value ? "opacity-40" : ""
}`}
onClick={handleStudyClick}
>
Study
</button>
);
}

View File

@ -1,77 +0,0 @@
// islands/StudyButton.tsx
import { useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
interface SyncButtonProps {
setCanStudy: any;
}
export default function SyncButton(
{ setCanStudy }: SyncButtonProps,
) {
//const [clicked, setClicked] = useState(true);
const handleClick = () => {
//setClicked(true);
//onClick();
};
const handleStudyClick = (
event: JSX.TargetedMouseEvent<HTMLButtonElement>,
) => {
//console.log("clicked");
if (!setCanStudy.value) {
event.preventDefault();
}
};
return (
<a href="/resync">
<button
className={`w-8 h-8 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-full shadow-lg transition-all duration-300 ease-in-out transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 flex flex-col justify-center items-center ${
setCanStudy.value ? "opacity-0" : ""
}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
d="M3 12a9 9 0 0115.07-6.36"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 3v5h-5"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 12a9 9 0 01-15.07 6.36"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21v-5h5"
stroke="currentColor"
strokeWidth={2}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</a>
);
}

View File

@ -1,407 +0,0 @@
// islands/TranslationList.tsx
import { useEffect, useState } from "preact/hooks";
import { QuizToTextFile } from "../utils/QuizToTextFile.ts";
import { TextToQuizTranslations } from "../utils/TextToQuizTranslations.ts";
import { QuizConfig } from "../utils/types.ts";
//import { QuizData } from "../utils/database.ts";
import { AsyncYesNoPop } from "./AsyncYesNoPop.tsx";
export interface Translation {
id: number;
quiz_id: number;
owner_id: string;
question_text: string;
meaning_text: string;
order_num: number;
}
interface TranslationListProps {
quiz: QuizConfig;
userName: string;
}
export default function TranslationList(
{ quiz, userName }: TranslationListProps,
) {
// Theme classes are defined in styles.css with @media (prefers-color-scheme: dark)
const [ask, popupElement] = AsyncYesNoPop();
const [quizData, setQuizData] = useState<QuizConfig>(quiz);
const [quizConfigDataStr, setQuizConfigDataStr] = useState(
JSON.stringify(quiz),
);
const [quizNameStr, setQuizNameStr] = useState(quiz.quiz_name);
const [text1Str, setText1Str] = useState("");
const [text2Str, setText2Str] = useState("");
const [deleteIndexStr, setDeleteIndexStr] = useState("");
//const [fileContent, setFileContent] = useState("");
const [quiz_nameError, setquiz_nameError] = useState(false);
const [questionsError, setQuestionsError] = useState(false);
useEffect(() => {
const lockRoute = localStorage.getItem("lock-route");
if (lockRoute) {
globalThis.location.href = lockRoute;
}
//fetchQuizData();
}, []);
const justClear = async () => {
const clearQuiz = {
active_date: Date.now(),
quiz_mode: "",
owner_id: quiz.owner_id,
quiz_config_id: crypto.randomUUID(),
quiz_name: "",
questions: [],
results: [],
};
setQuizData(clearQuiz);
setQuizConfigDataStr(JSON.stringify(clearQuiz));
setQuizNameStr("");
setText1Str("");
setText2Str("");
setDeleteIndexStr("");
};
const handleSaveNewQuiz = async () => {
};
const handleUpdateTranslation = async (translation: Translation) => {
};
const handleAddTranslation = async () => {
};
const handleDeleteTranslation = async (translationId: number) => {
};
const uploadFile = () => {
setquiz_nameError(false);
const input = document.createElement("input");
input.type = "file";
input.accept = ".txt";
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) {
const newQuizName = (file.name as string)?.split(".txt")[0]
?.trim();
if (!quizNameStr && !newQuizName) {
setquiz_nameError(true);
return;
}
if (
!quizNameStr && newQuizName && newQuizName.length > 0
) {
setQuizNameStr(newQuizName);
}
const text = (await file.text())?.trim();
if (text && text.length > 0) {
const textToQuiz = new TextToQuizTranslations(quizData);
const parsedQuizData = textToQuiz.parseText(text);
setQuizData(parsedQuizData);
setQuizConfigDataStr(JSON.stringify(parsedQuizData));
}
}
};
input.click();
};
const downloadFile = async () => {
if (quizData.questions.length === 0) {
setQuestionsError(true);
return;
}
if (!quizNameStr || quizNameStr.length === 0) {
setquiz_nameError(true);
return;
}
setQuestionsError(false);
const quizToText = new QuizToTextFile(quizData);
const content = quizToText.generateTextContent();
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${quizNameStr}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleSaveAddQuiz = async (event: Event) => {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
if (deleteIndexStr) {
const userConfirmed = await ask(
"Are you sure you want to delete?",
Date.now(),
);
if (userConfirmed) {
form.submit();
}
} else {
if (!quizNameStr || quizNameStr.length === 0) {
setquiz_nameError(true);
} else {
form.submit();
}
}
};
return (
<>
{popupElement}
<div
className={`theme-container p-2 sm:p-3 md:p-4 rounded-lg shadow-md max-w-md mx-auto w-full overflow-auto`}
>
<div className="flex space-x-2 pb-[10px]">
<button
onClick={uploadFile}
className={`flex-1 theme-button py-2 px-2 rounded-md transition duration-300`}
>
Add from file
</button>
<button
onClick={downloadFile}
className={`flex-1 theme-button py-2 px-2 rounded-md transition duration-300`}
>
Out to file
</button>
<button
onClick={justClear}
className={`flex-1 theme-button rounded-md transition duration-300`}
>
Clear
</button>
</div>
<form
action="/language/lists"
method="POST"
onSubmit={handleSaveAddQuiz}
>
<input
type="hidden"
name="quizConfigData"
value={quizConfigDataStr}
/>
<input
type="hidden"
name="quizNameStrData"
value={quizNameStr}
/>
<input
type="hidden"
name="text1StrData"
value={text1Str}
/>
<input
type="hidden"
name="text2StrData"
value={text2Str}
/>
<input
type="hidden"
name="deleteIndexStrData"
value={deleteIndexStr}
/>
<div className="mb-4 sm:mb-6">
<input
type="text"
value={quizNameStr}
onChange={(e) => {
const target = (e.target as HTMLInputElement).value.trim();
if (!target) {
setQuizNameStr(quizNameStr);
} else {
setQuizNameStr(target);
}
}}
onKeyDown={() => setquiz_nameError(false)}
placeholder="Quiz Name"
className={`w-full p-2 border rounded theme-input ${
quiz_nameError ? "border-red-500" : ""
}`}
/>
</div>
<div className="mb-4 sm:mb-6">
<h3 className={`text-lg sm:text-xl font-semibold mb-2 theme-text`}>
Add New Translation
</h3>
<div className="flex flex-col space-y-2">
<input
type="text"
value={text1Str}
onChange={(e) => {
const target = (e.target as HTMLInputElement).value.trim();
setText1Str(target);
}}
placeholder="Bisaya"
onKeyDown={() => setQuestionsError(false)}
className={`flex-1 p-2 border rounded theme-input ${
questionsError ? "border-red-500" : ""
}`}
/>
<input
type="text"
value={text2Str}
onChange={(e) => {
const target = (e.target as HTMLInputElement).value.trim();
setText2Str(target);
}}
placeholder="English"
onKeyDown={() => setQuestionsError(false)}
className={`flex-1 p-2 border rounded theme-input ${
questionsError ? "border-red-500" : ""
}`}
/>
<button
type="submit"
// onClick={handleAddTranslation}
className={`theme-button px-4 py-2 rounded w-full`}
>
Add and Save
</button>
</div>
</div>
<ul className="space-y-2">
{quizData.questions.map((translation: any, index: number) => (
<li
key={translation.id}
className={`theme-secondary-container p-1 rounded relative group`}
>
<div className="flex flex-col scroll pr-2">
<div className={`w-full p-2 border rounded mb-2 theme-input`}>
{translation.text_1}
</div>
<div className={`w-full p-2 border rounded theme-input`}>
{translation.text_2}
</div>
</div>
{!(!!translation.audio_file_1 || !!translation.audio_file_2 ||
!!translation.video_file || !!translation.video_file_data ||
!!translation.background_file ||
!!translation.background_image_data)
? (
<button
type="submit"
onClick={() => {
setDeleteIndexStr(`${index}`);
}}
className="absolute top-1/2 -right-2 transform -translate-y-1/2 text-red-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
)
: (
<button
type="submit"
onClick={(e) => {
e.preventDefault();
}}
className="absolute top-1/2 -right-2 transform -translate-y-1/2 text-red-700 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<rect
x="5"
y="11"
width="14"
height="8"
rx="2"
stroke="currentColor"
strokeWidth={2}
fill="none"
/>
<path
d="M7 11V7a5 5 0 0110 0v4"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
<circle
cx="12"
cy="15"
r="1"
fill="currentColor"
/>
</svg>
</button>
)}
</li>
))}
</ul>
</form>
</div>
<div class="fixed bottom-4 inset-x-0 flex justify-center z-40 pointer-events-none">
<div class="flex gap-2 w-11/12 max-w-md pointer-events-auto">
<span class={`w-[200px] py-3 px-6`}></span>
<span class={`w-[16px]`}></span>
<button
type="button"
onClick={() => {
globalThis.location.href = "/language/dashboard";
}}
class={`w-[200px] theme-button font-semibold py-3 px-6 rounded-full shadow-lg text-center`}
>
Dashboard
</button>
</div>
</div>
</>
);
}
async function makeQuizWithName(
owner_id: string,
quiz_name: string,
): Promise<number> {
try {
const response = await fetch("/api/quiz", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
owner_id,
quiz_name,
}),
});
if (!response.ok) {
const errorData = await response.json();
console.error("Error creating quiz:", errorData.error);
return 0;
}
const result = await response.json();
return result.quiz_id;
} catch (error) {
console.error("Network error when creating quiz:", error);
return 0;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,263 +0,0 @@
import { useEffect, useRef, useState } from "preact/hooks";
interface VoiceRecorderProps {
text: string;
audio_file: string;
role: string;
recordingCallback: (data: Blob | null) => void;
}
export default function VoiceRecorder(
{ text, audio_file, role, recordingCallback }: VoiceRecorderProps,
) {
const [recording, setRecording] = useState(false);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [initialAudioUrl, setInitialAudioUrl] = useState<string | null>(null);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const selectedMimeTypeRef = useRef<string | undefined>(undefined);
const initialAudioRef = useRef<HTMLAudioElement>(null);
const pickBestAudioMimeType = (): string | undefined => {
if (typeof MediaRecorder === "undefined") return undefined;
if (typeof MediaRecorder.isTypeSupported !== "function") return undefined;
const candidates = [
"audio/mp4",
"audio/mp4;codecs=mp4a.40.2",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
];
return candidates.find((t) => MediaRecorder.isTypeSupported(t));
};
// Fetch and play the initial audio_file when the component mounts
useEffect(() => {
if (!audio_file) return;
const fetchAudio = async () => {
try {
const isDirectUrl = audio_file.startsWith("blob:") ||
audio_file.startsWith("http") ||
audio_file.startsWith("/");
if (isDirectUrl) {
setInitialAudioUrl(audio_file);
return;
}
const response = await fetch(
`/api/audio-blob/${encodeURIComponent(audio_file)}`,
{ credentials: "include" },
);
if (!response.ok) {
console.error("Failed to fetch audio file:", response.statusText);
return;
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setInitialAudioUrl(url);
} catch (err) {
console.error("Error fetching audio file:", err);
}
};
fetchAudio();
// Cleanup blob URL on unmount
return () => {
if (initialAudioUrl) {
URL.revokeObjectURL(initialAudioUrl);
}
};
}, [audio_file]);
// Auto-play when initial audio is loaded
useEffect(() => {
if (initialAudioUrl && initialAudioRef.current) {
initialAudioRef.current.play().catch(() => {
// Autoplay might be blocked, user must interact
});
}
}, [initialAudioUrl]);
const startRecording = async () => {
if (audioUrl) {
URL.revokeObjectURL(audioUrl);
}
setAudioUrl(null);
setAudioBlob(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
if (typeof MediaRecorder === "undefined") {
alert("Audio recording is not supported in this browser.");
stream.getTracks().forEach((t) => t.stop());
return;
}
const bestMimeType = pickBestAudioMimeType();
const mimeTypeCandidates = [
bestMimeType,
"audio/mp4",
"audio/mp4;codecs=mp4a.40.2",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
undefined,
].filter((t, i, arr) => arr.indexOf(t) === i);
let mediaRecorder: MediaRecorder | null = null;
let lastError: unknown = undefined;
for (const candidate of mimeTypeCandidates) {
try {
mediaRecorder = candidate
? new MediaRecorder(stream, { mimeType: candidate })
: new MediaRecorder(stream);
selectedMimeTypeRef.current = candidate;
break;
} catch (err) {
lastError = err;
mediaRecorder = null;
continue;
}
}
if (!mediaRecorder) {
console.error("Failed to create MediaRecorder", lastError);
alert("Audio recording is not supported in this browser.");
stream.getTracks().forEach((t) => t.stop());
return;
}
mediaRecorderRef.current = mediaRecorder;
mediaRecorder.onerror = (e) => {
const recErr = (e as ErrorEvent | Event) as unknown as {
error?: DOMException;
};
const err = recErr?.error;
console.error("MediaRecorder error", err || e);
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
alert(
"Recording failed. This Safari version may not support MediaRecorder with audio-only streams.",
);
};
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunksRef.current.push(e.data);
}
};
mediaRecorder.onstop = () => {
const fallbackType = chunksRef.current[0]?.type ||
mediaRecorder.mimeType ||
selectedMimeTypeRef.current ||
"";
const blob = new Blob(
chunksRef.current,
fallbackType ? { type: fallbackType } : undefined,
);
chunksRef.current = [];
setAudioBlob(blob);
setAudioUrl(URL.createObjectURL(blob));
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorder.start();
setRecording(true);
} catch (err) {
alert("Microphone access denied or not supported.");
}
};
const stopRecording = () => {
mediaRecorderRef.current?.stop();
setRecording(false);
};
const handleSave = () => {
if (audioBlob) {
recordingCallback(audioBlob);
}
};
const handleClose = () => {
recordingCallback(null);
};
return (
<div className="theme-popup-overlay">
<div
className="theme-recorder-container"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={handleClose}
aria-label="Close"
className="theme-recorder-close"
>
×
</button>
<div className="flex-1 flex items-center justify-center w-full mb-6">
<div className="text-center w-full">{text}</div>
</div>
{/* Initial audio playback */}
<div className="mb-4 w-full flex justify-center">
{initialAudioUrl && (
<audio
ref={initialAudioRef}
src={initialAudioUrl}
autoPlay
controls
className="w-full"
/>
)}
</div>
{role === "admin" && (
<div className="flex gap-4 justify-center w-full mb-4">
<button
className={`theme-recorder-button theme-recorder-start ${
recording ? "opacity-60 pointer-events-none" : ""
}`}
onClick={startRecording}
disabled={recording}
>
🎤 Start
</button>
<button
className={`theme-recorder-button theme-recorder-stop ${
!recording ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={stopRecording}
disabled={!recording}
>
⏹ Stop
</button>
<button
className={`theme-recorder-button theme-recorder-save ${
!audioUrl ? "opacity-50 cursor-not-allowed" : ""
}`}
onClick={handleSave}
disabled={!audioUrl}
>
💾 Save
</button>
</div>
)}
<div className="mt-4 h-[30px] w-full flex justify-center items-center">
{audioUrl && <audio controls src={audioUrl}></audio>}
</div>
</div>
</div>
);
}

View File

@ -1,101 +0,0 @@
// routes/api/brain/debug.ts
// Debug endpoint to verify brain progress persistence across restarts
import { define, State } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { brainDataClient } from "../../../utils/BrainDataClient.ts";
import {
sqlGetAllBrainProgress,
sqlGetBrainProgressByUser,
} from "../../../db/sqlUserRepository.ts";
export const handler = define.handlers({
// GET: Debug info about brain progress persistence
async GET(ctx) {
const state = ctx.state as State;
const stateData: SessionCookie = state.data ?? {
started_on: 0,
user_name: "",
user_id: "",
role: "",
user_encrypted: false,
};
if (!state.stateId || !stateData.user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
try {
// Get progress from BrainDataClient (uses KV + SQL via KvCacheStore)
const kvProgress = await brainDataClient.getUserProgress(
stateData.user_id,
);
// Get progress directly from SQL (bypasses KV cache)
const sqlProgress = await sqlGetBrainProgressByUser(stateData.user_id);
// Get all SQL progress (for admin debugging)
const allSqlProgress = await sqlGetAllBrainProgress();
// Count records with is_review_only = true
const kvMasteredCount = kvProgress.filter((p) => p.is_review_only).length;
const sqlMasteredCount = sqlProgress.filter((p) =>
p.is_review_only
).length;
return new Response(
JSON.stringify({
success: true,
user_id: stateData.user_id,
persistence_status: {
kv_record_count: kvProgress.length,
sql_record_count: sqlProgress.length,
all_sql_record_count: allSqlProgress.length,
kv_mastered_count: kvMasteredCount,
sql_mastered_count: sqlMasteredCount,
records_match: kvProgress.length === sqlProgress.length,
},
// Include first few records for debugging
kv_sample: kvProgress.slice(0, 3).map((p) => ({
id: p.id,
question_id: p.question_id,
current_mode: p.current_mode,
mode_index: p.mode_index,
is_review_only: p.is_review_only,
attempts: p.attempts,
})),
sql_sample: sqlProgress.slice(0, 3).map((p) => ({
id: p.id,
question_id: p.question_id,
current_mode: p.current_mode,
mode_index: p.mode_index,
is_review_only: p.is_review_only,
attempts: p.attempts,
})),
// Diagnostic info
diagnostics: {
kv_mastered_ids: kvProgress
.filter((p) => p.is_review_only)
.map((p) => p.question_id),
sql_mastered_ids: sqlProgress
.filter((p) => p.is_review_only)
.map((p) => p.question_id),
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error in brain debug endpoint:", error);
return new Response(
JSON.stringify({
error: "Failed to fetch debug info",
details: error instanceof Error ? error.message : String(error),
}),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
});

View File

@ -1,160 +0,0 @@
// routes/api/brain/progress.ts
// API endpoint for Brain study progress with mode progression
import { define, State } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { brainDataClient } from "../../../utils/BrainDataClient.ts";
export const handler = define.handlers({
// GET: Fetch user's brain progress stats
async GET(ctx) {
const state = ctx.state as State;
const stateData: SessionCookie = state.data ?? {
started_on: 0,
user_name: "",
user_id: "",
role: "",
user_encrypted: false,
};
if (!state.stateId || !stateData.user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
try {
const stats = await brainDataClient.getStats(stateData.user_id);
const dueCount = await brainDataClient.getDueCount(stateData.user_id);
return new Response(
JSON.stringify({
success: true,
stats,
dueCount,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error fetching brain progress:", error);
return new Response(
JSON.stringify({ error: "Failed to fetch progress" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
// POST: Record answer result with mode progression
async POST(ctx) {
const state = ctx.state as State;
const stateData: SessionCookie = state.data ?? {
started_on: 0,
user_name: "",
user_id: "",
role: "",
user_encrypted: false,
};
if (!state.stateId || !stateData.user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
try {
const body = await ctx.req.json();
const { questionId, quizId, wasCorrectFirstTry } = body;
// Mode is no longer required - it's tracked per-card by the backend
if (!questionId || !quizId || typeof wasCorrectFirstTry !== "boolean") {
return new Response(
JSON.stringify({
error:
"Missing required fields: questionId, quizId, wasCorrectFirstTry",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const progress = await brainDataClient.recordAnswer(
stateData.user_id,
questionId,
quizId,
wasCorrectFirstTry,
);
return new Response(
JSON.stringify({
success: true,
progress,
// Include mode advancement info for UI feedback
modeAdvanced: wasCorrectFirstTry && progress.mode_index > 0,
isReviewOnly: progress.is_review_only,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error recording brain progress:", error);
return new Response(
JSON.stringify({ error: "Failed to record progress" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
// DELETE: Reset progress
async DELETE(ctx) {
const state = ctx.state as State;
const stateData: SessionCookie = state.data ?? {
started_on: 0,
user_name: "",
user_id: "",
role: "",
user_encrypted: false,
};
if (!state.stateId || !stateData.user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const url = new URL(ctx.req.url);
const questionId = url.searchParams.get("questionId");
const quizId = url.searchParams.get("quizId");
const resetAll = url.searchParams.get("all") === "true";
try {
if (resetAll) {
await brainDataClient.resetAllProgress(stateData.user_id);
} else if (quizId) {
await brainDataClient.resetQuizProgress(stateData.user_id, quizId);
} else if (questionId) {
// Mode is no longer needed - progress is per-question now
await brainDataClient.resetQuestionProgress(
stateData.user_id,
questionId,
);
} else {
return new Response(
JSON.stringify({ error: "Missing reset parameters" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error resetting brain progress:", error);
return new Response(
JSON.stringify({ error: "Failed to reset progress" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
});

View File

@ -1,108 +0,0 @@
// routes/api/brain/queue.ts
// API endpoint for building Brain study queue with mode progression
import { define, State } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { brainDataClient } from "../../../utils/BrainDataClient.ts";
import { getAllKvQuizConfigs } from "../../../utils/QuizDataClient.ts";
import { DEFAULT_QUIZ_MODE, QUIZ_MODES } from "../../../utils/quizModes.ts";
export const handler = define.handlers({
// GET: Build and return study queue for user
async GET(ctx) {
const state = ctx.state as State;
const stateData: SessionCookie = state.data ?? {
started_on: 0,
user_name: "",
user_id: "",
role: "",
user_encrypted: false,
};
if (!state.stateId || !stateData.user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
const url = new URL(ctx.req.url);
// Mode parameter is now deprecated - each card has its own mode based on progression
// We still accept it for backwards compatibility but it's ignored
const newCardsLimit = parseInt(
url.searchParams.get("newLimit") || "10",
10,
);
const totalLimit = parseInt(url.searchParams.get("limit") || "50", 10);
// Parse tab_index from query parameters for filtering
const rawTabIndex = url.searchParams.get("tab_index");
let tabIndex: number | undefined = undefined;
if (rawTabIndex !== null) {
const parsed = Number.parseInt(rawTabIndex, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
tabIndex = parsed;
}
}
try {
// Get all quiz configs for the user
let quizConfigs = await getAllKvQuizConfigs(stateData.user_id);
// Filter by tab_index if provided
if (tabIndex !== undefined) {
quizConfigs = quizConfigs.filter((kvObj) => {
const quizTabIndex = kvObj.value?.tab_index ?? 0;
return quizTabIndex === tabIndex;
});
}
// Build the study queue (mode is now per-card based on progression)
const queue = await brainDataClient.buildStudyQueue(
stateData.user_id,
quizConfigs,
DEFAULT_QUIZ_MODE, // This is now ignored internally but kept for API compatibility
newCardsLimit,
totalLimit,
);
// Get stats
const stats = await brainDataClient.getStats(stateData.user_id);
// Calculate counts based on new locked-set behavior:
// - newCount: cards still in active progression (either truly new or in-progress through modes)
// - dueCount: cards that have completed all modes and are now in spaced repetition review
// - reviewOnlyCount: same as dueCount (review-only cards)
const activeSetCards = queue.filter((q) => q.is_new && !q.is_review_only);
const reviewCards = queue.filter((q) => q.is_review_only);
return new Response(
JSON.stringify({
success: true,
queue,
stats,
// Return default mode for backwards compatibility
mode: DEFAULT_QUIZ_MODE,
// Include the full mode list so client knows the progression order
modeOrder: QUIZ_MODES,
totalInQueue: queue.length,
// Active set count: cards currently being mastered through 6 modes
activeSetCount: activeSetCards.length,
// New count: for UI, same as active set (cards not yet fully mastered)
newCount: activeSetCards.length,
// Review-only count: cards that have completed all 6 modes
reviewOnlyCount: reviewCards.length,
// Due count: for backwards compatibility, set to review-only count
dueCount: reviewCards.length,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error building brain queue:", error);
return new Response(
JSON.stringify({ error: "Failed to build queue" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
});

View File

@ -1,46 +0,0 @@
// routes/api/brain/reset-session.ts
// API endpoint for resetting all Brain study progress for a user
import { define, State } from "../../../utils.ts";
import { SessionCookie } from "../../auth/callback.ts";
import { brainDataClient } from "../../../utils/BrainDataClient.ts";
export const handler = define.handlers({
// DELETE: Reset all quiz progress for the current user
async DELETE(ctx) {
const state = ctx.state as State;
const stateData: SessionCookie = state.data ?? {
started_on: 0,
user_name: "",
user_id: "",
role: "",
user_encrypted: false,
};
if (!state.stateId || !stateData.user_id) {
return new Response(
JSON.stringify({ error: "Unauthorized" }),
{ status: 401, headers: { "Content-Type": "application/json" } },
);
}
try {
// Reset all progress for this user
await brainDataClient.resetAllProgress(stateData.user_id);
return new Response(
JSON.stringify({
success: true,
message: "All quiz progress has been reset",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error resetting brain session:", error);
return new Response(
JSON.stringify({ error: "Failed to reset session" }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
},
});

View File

@ -1,425 +0,0 @@
// utils/BrainDataClient.ts
// Client for managing Brain study progress data with mode progression
import { kvStore } from "../modules/KvCacheStore.ts";
import {
BrainQuestionProgress,
BrainQueueItem,
KvObj,
QuizConfig,
} from "./types.ts";
import { calculateNextReview, isDue } from "./spacedRepetition.ts";
import {
DEFAULT_QUIZ_MODE,
getModeIndex,
getNextMode,
QUIZ_MODES,
} from "./quizModes.ts";
// How many days to push out review-only cards
const REVIEW_ONLY_INTERVAL_DAYS = 30;
const REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW = 30;
function shuffleInPlace<T>(arr: T[]) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
/**
* Generate composite ID for brain progress record (mode-agnostic)
*/
export function generateBrainProgressId(
userId: string,
questionId: string,
): string {
return `${userId}_${questionId}`;
}
/**
* Client for managing Brain study progress with mode progression
*/
export class BrainDataClient {
/**
* Get all progress records for a user
*/
async getUserProgress(userId: string): Promise<BrainQuestionProgress[]> {
const progress: BrainQuestionProgress[] = [];
for await (
const entry of kvStore.list<BrainQuestionProgress>({
prefix: ["brain_question_progress", userId],
})
) {
if (entry.value) {
progress.push(entry.value);
}
}
return progress;
}
/**
* Get a specific progress record for a question (mode-agnostic)
*/
async getProgress(
userId: string,
questionId: string,
): Promise<BrainQuestionProgress | null> {
const id = generateBrainProgressId(userId, questionId);
const key = ["brain_question_progress", userId, id];
return await kvStore.get<BrainQuestionProgress>(key);
}
/**
* Get all questions due for review
*/
async getDueQuestions(userId: string): Promise<BrainQuestionProgress[]> {
const allProgress = await this.getUserProgress(userId);
return allProgress.filter((p) => isDue(p.next_due_at));
}
/**
* Record an answer and update progress with mode progression logic
*
* Mode progression rules:
* - When wasCorrectFirstTry is true and not in final mode: advance to next mode
* - When wasCorrectFirstTry is true and in final mode: mark as review-only
* - When wasCorrectFirstTry is false: stay in current mode, reset consecutive streak
*/
async recordAnswer(
userId: string,
questionId: string,
quizId: string,
wasCorrectFirstTry: boolean,
): Promise<BrainQuestionProgress> {
const id = generateBrainProgressId(userId, questionId);
const key = ["brain_question_progress", userId, id];
const now = Date.now();
// Get existing progress or create new
const existing = await kvStore.get<BrainQuestionProgress>(key);
// Determine current mode and index
let currentMode = existing?.current_mode ?? DEFAULT_QUIZ_MODE;
let modeIndex = existing?.mode_index ?? 0;
let consecutiveFirstTry = existing?.consecutive_first_try ?? 0;
let isReviewOnly = existing?.is_review_only ?? false;
let currentInterval = existing?.interval_days ?? 1;
const currentEase = existing?.ease_factor ?? 2.5;
// Mode progression logic
if (wasCorrectFirstTry) {
consecutiveFirstTry++;
// Advance to next mode if correct on first try
const nextMode = getNextMode(currentMode);
if (nextMode !== null) {
// Move to next harder mode
currentMode = nextMode;
modeIndex = getModeIndex(nextMode);
// Reset interval for the new mode (card needs to be reviewed soon in new mode)
currentInterval = 1;
} else {
// Already at final mode and correct on first try - mark as review-only
isReviewOnly = true;
// Push due date far out for review-only cards
currentInterval = REVIEW_ONLY_INTERVAL_DAYS;
}
} else {
// Incorrect: reset consecutive streak, stay in current mode
consecutiveFirstTry = 0;
// Don't reset mode - keep in current mode until they get it right
}
// Calculate new review schedule
const { nextDueAt, newInterval, newEaseFactor } = calculateNextReview(
wasCorrectFirstTry,
currentInterval,
currentEase,
);
// For review-only cards, use longer interval
const finalInterval = isReviewOnly
? Math.max(newInterval, REVIEW_ONLY_INTERVAL_DAYS)
: newInterval;
const finalNextDueAt = isReviewOnly
? now + (finalInterval * 24 * 60 * 60 * 1000)
: nextDueAt;
const progress: BrainQuestionProgress = {
id,
user_id: userId,
quiz_id: quizId,
question_id: questionId,
current_mode: currentMode,
mode_index: modeIndex,
attempts: (existing?.attempts ?? 0) + 1,
first_try_successes: (existing?.first_try_successes ?? 0) +
(wasCorrectFirstTry ? 1 : 0),
consecutive_first_try: consecutiveFirstTry,
last_result: wasCorrectFirstTry,
next_due_at: finalNextDueAt,
interval_days: finalInterval,
ease_factor: newEaseFactor,
is_review_only: isReviewOnly,
created_on: existing?.created_on ?? now,
updated_on: now,
};
await kvStore.set(key, progress);
return progress;
}
/**
* Build study queue from all user's quizzes
* Returns cards sorted by mode (easier modes first), then by due status
* Review-only cards are deprioritized unless there are too few active cards
*/
async buildStudyQueue(
userId: string,
quizConfigs: KvObj<QuizConfig>[],
_mode: string, // Deprecated - mode is now per-card based on progression
newCardsLimit: number = 10,
totalLimit: number = 50,
): Promise<BrainQueueItem[]> {
const now = Date.now();
// Get all existing progress for this user
const allProgress = await this.getUserProgress(userId);
const progressMap = new Map<string, BrainQuestionProgress>();
for (const p of allProgress) {
progressMap.set(p.question_id, p);
}
// Build queue items from all questions
const allItems: {
item: BrainQueueItem;
nextDueAt: number | null;
isNew: boolean; // Truly new (never seen)
isInProgression: boolean; // Has started but not completed all modes
modeIndex: number;
isReviewOnly: boolean;
isDue: boolean;
isReviewLongEnough: boolean;
}[] = [];
for (const kvObj of quizConfigs) {
if (!kvObj.value?.questions) continue;
for (const question of kvObj.value.questions) {
const progress = progressMap.get(question.id) ?? null;
// "Truly new" = never seen before (no progress record)
const isTrulyNew = progress === null;
// "In progression" = has started but hasn't completed all 6 modes yet
const isInProgression = progress !== null && !progress.is_review_only;
// For UI: "new" means either truly new or still in active progression
const isNew = isTrulyNew || isInProgression;
// Determine the mode for this card
const displayMode = progress?.current_mode ?? DEFAULT_QUIZ_MODE;
const modeIndex = progress?.mode_index ?? 0;
const isReviewOnly = progress?.is_review_only ?? false;
const nextDueAt = progress?.next_due_at ?? null;
const cardIsDue = isDue(nextDueAt);
const lastReviewedAt = progress?.updated_on ?? progress?.created_on ??
null;
const isReviewLongEnough = !isReviewOnly || lastReviewedAt === null
? true
: (now - lastReviewedAt) >=
(REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW * 24 * 60 * 60 * 1000);
allItems.push({
item: {
question_id: question.id,
quiz_id: kvObj.value.quiz_config_id,
question_data: question,
progress,
is_new: isNew,
display_mode: displayMode,
mode_index: modeIndex,
is_review_only: isReviewOnly,
},
nextDueAt,
isNew: isTrulyNew, // For queue building, only count truly new cards toward newLimit
isInProgression,
modeIndex,
isReviewOnly,
isDue: cardIsDue,
isReviewLongEnough,
});
}
}
// Separate cards into categories for the locked-set study behavior:
// 1. Cards currently in progression (started but not finished all 6 modes) - highest priority
// IMPORTANT: Do NOT gate on isDue - active-learning cards should remain eligible
// even if next_due_at is in the future, so the user can complete all 6 modes in one session
// 2. Truly new cards (never seen) - these fill the active set up to newLimit
// 3. Review-only cards (completed all modes, now in spaced repetition) - only if due
const inProgressionCards = allItems.filter(
(item) => item.isInProgression, // No isDue check - always include in-progression cards
);
const trulyNewCards = allItems.filter((item) => item.isNew);
const reviewOnlyCards = allItems.filter(
(item) => item.isReviewOnly && item.isDue && item.isReviewLongEnough, // Review-only respects due and minimum time since last review
);
// Sort each category by mode index (easier modes first for learning progression)
const sortByModeAndDue = (
a: typeof allItems[0],
b: typeof allItems[0],
) => {
// Primary: mode index (lower = easier = first)
if (a.modeIndex !== b.modeIndex) {
return a.modeIndex - b.modeIndex;
}
// Secondary: due status (more overdue first)
const aDue = a.nextDueAt ?? Date.now();
const bDue = b.nextDueAt ?? Date.now();
return aDue - bDue;
};
inProgressionCards.sort(sortByModeAndDue);
trulyNewCards.sort(sortByModeAndDue);
reviewOnlyCards.sort(sortByModeAndDue);
// Randomize the pool of new cards so large queue requests don't return the same order
// Keep in-progression cards sorted/stable so users can reliably finish what they've started.
shuffleInPlace(trulyNewCards);
shuffleInPlace(reviewOnlyCards);
// Build final queue using locked-set behavior:
// - First, include all cards currently in progression (they're part of the active set)
// - Then, fill up to newCardsLimit with truly new cards
// - Review-only cards are only added if specifically requested (not mixed into active set)
const queue: BrainQueueItem[] = [];
// 1. Add all cards currently in progression (these are the "active set" that must be completed)
for (const { item } of inProgressionCards) {
if (queue.length >= totalLimit) break;
queue.push(item);
}
// 2. Calculate how many slots are available for new cards
// The active set should be up to newCardsLimit (default 20)
const activeSetCount = queue.length;
const slotsForNew = Math.max(0, newCardsLimit - activeSetCount);
// 3. Add truly new cards to fill the active set up to the limit
let newCardsAdded = 0;
for (const { item } of trulyNewCards) {
if (queue.length >= totalLimit || newCardsAdded >= slotsForNew) break;
queue.push(item);
newCardsAdded++;
}
// 4. Only add review-only cards if there are no active new/progression cards
// This ensures the user focuses on completing the active set before reviews
if (queue.length === 0) {
for (const { item } of reviewOnlyCards) {
if (queue.length >= totalLimit) break;
queue.push(item);
}
}
return queue;
}
/**
* Get count of questions due for review
*/
async getDueCount(userId: string, _mode?: string): Promise<number> {
const allProgress = await this.getUserProgress(userId);
// Mode parameter is deprecated - count all due cards
return allProgress.filter((p) => isDue(p.next_due_at)).length;
}
/**
* Get statistics for a user
*/
async getStats(userId: string, _mode?: string): Promise<{
totalReviewed: number;
totalCorrect: number;
dueNow: number;
accuracy: number;
reviewOnlyCount: number;
modeDistribution: Record<string, number>;
}> {
const allProgress = await this.getUserProgress(userId);
const totalReviewed = allProgress.reduce(
(sum, p) => sum + (p.attempts ?? 0),
0,
);
const totalCorrect = allProgress.reduce(
(sum, p) => sum + (p.first_try_successes ?? 0),
0,
);
const dueNow = allProgress.filter((p) => isDue(p.next_due_at)).length;
const accuracy = totalReviewed > 0
? (totalCorrect / totalReviewed) * 100
: 0;
const reviewOnlyCount = allProgress.filter((p) => p.is_review_only).length;
// Count cards by current mode
const modeDistribution: Record<string, number> = {};
for (const mode of QUIZ_MODES) {
modeDistribution[mode] = 0;
}
for (const p of allProgress) {
const mode = p.current_mode ?? DEFAULT_QUIZ_MODE;
modeDistribution[mode] = (modeDistribution[mode] ?? 0) + 1;
}
return {
totalReviewed,
totalCorrect,
dueNow,
accuracy: Math.round(accuracy * 10) / 10,
reviewOnlyCount,
modeDistribution,
};
}
/**
* Reset progress for a specific question
*/
async resetQuestionProgress(
userId: string,
questionId: string,
): Promise<void> {
const id = generateBrainProgressId(userId, questionId);
const key = ["brain_question_progress", userId, id];
await kvStore.delete(key);
}
/**
* Reset all progress for a quiz
*/
async resetQuizProgress(userId: string, quizId: string): Promise<void> {
const allProgress = await this.getUserProgress(userId);
for (const p of allProgress) {
if (p.quiz_id === quizId) {
const key = ["brain_question_progress", userId, p.id];
await kvStore.delete(key);
}
}
}
/**
* Reset all progress for a user
*/
async resetAllProgress(userId: string): Promise<void> {
const allProgress = await this.getUserProgress(userId);
for (const p of allProgress) {
const key = ["brain_question_progress", userId, p.id];
await kvStore.delete(key);
}
}
}
// Export singleton instance
export const brainDataClient = new BrainDataClient();

View File

@ -1,46 +0,0 @@
//import { QuizData } from "./database.ts";
import { QuizConfig } from "./types.ts";
// Class to handle quiz to text file conversion
export class QuizToTextFile {
quizData: QuizConfig;
constructor(quizData: QuizConfig) {
this.quizData = quizData;
}
generateTextContent() {
// let content = `QUIZ_ID: ${this.quizData.quiz_id}\n`;
// content += `QUIZ_NAME: ${this.quizData.quiz_name}\n`;
// content += `OWNER_ID: ${this.quizData.owner_id}\n\n`;
let content = "";
this.quizData.questions.forEach((question, index) => {
// content += `Q${index + 1}_ID: ${question.id}\n`;
// content += `Q${index + 1}_LANGUAGE: ${question.language_id}\n`;
content += `${question.text_1}\n`;
content += `${question.text_2}\n\n`;
// content += `Q${index + 1}_ORDER: ${question.order_num}\n\n`;
});
return content;
}
async saveToFile(fileName: string) {
const content = this.generateTextContent();
try {
await Deno.writeTextFile(fileName, content);
//console.log(`Quiz saved to ${fileName}`);
} catch (error) {
//console.error(`Error saving quiz to file: ${error}`);
}
}
}
// Example usage:
// const quizData = { /* ... */ };
// const quizToText = new QuizToTextFile(quizData);
// await quizToText.saveToFile('quiz_translations.txt');
// const textToQuiz = new TextToQuizTranslations();
// const parsedQuizData = await textToQuiz.parseFile('quiz_translations.txt');
// console.log(parsedQuizData);

View File

@ -1,105 +0,0 @@
//import { QuizData } from "./database.ts";
import { QuizConfig } from "./types.ts";
// Class to parse text and add translations
export class TextToQuizTranslations {
quizData: QuizConfig;
constructor(quiz: QuizConfig) {
this.quizData = {
quiz_config_id: quiz.quiz_config_id,
owner_id: quiz.owner_id,
quiz_name: quiz.quiz_name,
active_date: quiz.active_date,
quiz_mode: quiz.quiz_mode,
questions: quiz.questions,
results: quiz.results,
};
}
parseText(text: string) {
const lines = text.split("\n");
let question = "";
let meaning = "";
const languageLines: string[] = [];
lines.forEach((value: string) => {
const thisLine = value.trim();
if (thisLine !== "") {
languageLines.push(thisLine);
}
});
const newQuestions: any = [];
let nextQuestionIndex = this.quizData.questions.length;
languageLines.forEach((value: string, index: number) => {
const sequence = index % 2;
if (sequence == 0) {
question = value;
} else {
meaning = value;
const thisQuestion: {
id: string;
question_index?: number;
quiz_id: string;
owner_id: string;
language_1: string;
language_2: string;
text_1: string;
audio_file_1: string;
text_2: string;
audio_file_2: string;
order_num: number;
background_file: string;
background_image_data: string;
video_file: string;
video_file_data: string;
} = {
quiz_id: this.quizData.quiz_config_id,
owner_id: this.quizData.owner_id,
question_index: nextQuestionIndex,
language_1: "bis-ph",
language_2: "en-us",
text_1: question,
text_2: meaning,
audio_file_1: "",
audio_file_2: "",
background_file: "",
background_image_data: "",
id: crypto.randomUUID(),
order_num: 1,
video_file: "",
video_file_data: "",
};
nextQuestionIndex++;
newQuestions.push(thisQuestion);
}
});
this.quizData.questions = [...newQuestions, ...this.quizData.questions];
return this.quizData;
}
async parseFile(fileName: string) {
try {
const text = await Deno.readTextFile(fileName);
return this.parseText(text);
} catch (error) {
console.error(`Error reading file: ${error}`);
return null;
}
}
}
// Example usage:
// const quizData = { /* ... */ };
// const quizToText = new QuizToTextFile(quizData);
// await quizToText.saveToFile('quiz_translations.txt');
// const textToQuiz = new TextToQuizTranslations();
// const parsedQuizData = await textToQuiz.parseFile('quiz_translations.txt');
// console.log(parsedQuizData);

View File

@ -1,92 +0,0 @@
// utils/quizModes.ts
// Shared canonical quiz mode definitions matching QuizConfigure.tsx
/**
* Ordered list of quiz modes from easiest to hardest.
* This order is used for mode progression in BrainStudy.
*
* Mode meanings:
* - photo-see-bisaya: Show photo, display Bisaya text, user types English
* - photo-see-english: Show photo, display English text, user types Bisaya
* - photo-hide-bisaya: Show photo, hide Bisaya (audio only), user types Bisaya
* - photo-hide-english: Show photo, hide English, user types English
* - blank-see-bisaya: No photo, display Bisaya text, user types English
* - blank-see-english: No photo, display English text, user types Bisaya
*/
export const QUIZ_MODES = [
"photo-see-bisaya",
"photo-see-english",
"photo-hide-bisaya",
"photo-hide-english",
"blank-see-bisaya",
"blank-see-english",
] as const;
export type QuizMode = typeof QUIZ_MODES[number];
/**
* Default/starting mode for new cards
*/
export const DEFAULT_QUIZ_MODE: QuizMode = QUIZ_MODES[0];
/**
* Get the index of a mode in the progression order
*/
export function getModeIndex(mode: string): number {
const index = QUIZ_MODES.indexOf(mode as QuizMode);
return index >= 0 ? index : 0;
}
/**
* Get the next mode in the progression
* Returns null if already at the final mode
*/
export function getNextMode(currentMode: string): QuizMode | null {
const currentIndex = getModeIndex(currentMode);
if (currentIndex >= QUIZ_MODES.length - 1) {
return null; // Already at final mode
}
return QUIZ_MODES[currentIndex + 1];
}
/**
* Check if a mode is the final mode in the progression
*/
export function isFinalMode(mode: string): boolean {
return getModeIndex(mode) === QUIZ_MODES.length - 1;
}
/**
* Get display label for a mode
*/
export function getModeLabel(mode: string): string {
const labels: Record<string, string> = {
"photo-see-bisaya": "Photo, See Bisaya",
"photo-see-english": "Photo, See English",
"photo-hide-bisaya": "Blank, Hide Bisaya",
"photo-hide-english": "Photo, Hide English",
"blank-see-bisaya": "Blank, See Bisaya",
"blank-see-english": "Blank, See English",
};
return labels[mode] || mode;
}
/**
* Get mode display info for UI
*/
export function getModeDisplayInfo(mode: string): {
index: number;
total: number;
label: string;
isFirst: boolean;
isFinal: boolean;
} {
const index = getModeIndex(mode);
return {
index: index + 1, // 1-based for display
total: QUIZ_MODES.length,
label: getModeLabel(mode),
isFirst: index === 0,
isFinal: index === QUIZ_MODES.length - 1,
};
}