Update date validation to allow for negative timestamps across multiple components. Modify AddDialog, HistoryViewDialog, and DateUtils to ensure consistent handling of timestamp inputs, enhancing robustness in date parsing.

This commit is contained in:
Lê Thành Đạt committed 2026-04-06 00:03:13 -04:00
1 parent 360573bc6b
commit 4b6565ec05
8 files changed
+779 -4

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,462 @@
package BsK.client.network;
import BsK.client.network.handler.ClientHandler;
import BsK.common.exception.NetworkException;
import BsK.common.packet.Packet;
import BsK.common.packet.PacketSerializer;
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.DefaultHttpHeaders;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler;
import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import io.netty.handler.timeout.IdleStateHandler;
import lombok.extern.slf4j.Slf4j;
import javax.swing.SwingUtilities;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Properties;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Manages the WebSocket connection lifecycle with automatic reconnection support.
* This singleton handles connection, disconnection, and reconnection with exponential backoff.
*/
@Slf4j
public class ConnectionManager {
private static final ConnectionManager INSTANCE = new ConnectionManager();
public static ConnectionManager getInstance() {
return INSTANCE;
}
/**
* Connection states
*/
public enum ConnectionState {
DISCONNECTED,
CONNECTING,
CONNECTED,
RECONNECTING
}
/**
* Listener interface for connection state changes
*/
public interface ConnectionStateListener {
void onStateChanged(ConnectionState newState, String message);
void onReconnectCountdown(int secondsRemaining, int attemptNumber);
}
// Connection configuration
private String serverAddress = "localhost";
private int serverPort = 1999;
private URI serverUri;
// Connection state
private final AtomicReference<ConnectionState> state = new AtomicReference<>(ConnectionState.DISCONNECTED);
private volatile Channel channel;
private EventLoopGroup eventLoopGroup;
// Reconnection settings
private static final int INITIAL_RETRY_DELAY_SECONDS = 1;
private static final int MAX_RETRY_DELAY_SECONDS = 30;
private static final double BACKOFF_MULTIPLIER = 2.0;
// Ping/Pong timeout settings - MORE TOLERANT (1 minute total)
// Send ping every 15 seconds, allow 4 missed pongs = 60 seconds tolerance
private static final int WRITER_IDLE_SECONDS = 15; // Send ping every 15 seconds when idle
private static final int READER_IDLE_SECONDS = 90; // Fallback: no data for 90 seconds = disconnect
private final AtomicInteger currentRetryDelay = new AtomicInteger(INITIAL_RETRY_DELAY_SECONDS);
private final AtomicInteger reconnectAttempt = new AtomicInteger(0);
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "ConnectionManager-Scheduler");
t.setDaemon(true);
return t;
});
private ScheduledFuture<?> reconnectFuture;
private ScheduledFuture<?> countdownFuture;
// Listeners
private final List<ConnectionStateListener> listeners = new CopyOnWriteArrayList<>();
// Track if handshake is complete
private volatile boolean handshakeComplete = false;
private ConnectionManager() {
// Private constructor for singleton
}
/**
* Initialize connection parameters from config
*/
public void initialize(Properties config) {
this.serverAddress = config.getProperty("server.address", "localhost");
String portStr = config.getProperty("server.port", "1999");
this.serverPort = Integer.parseInt(portStr);
try {
this.serverUri = new URI("ws://" + serverAddress + ":" + serverPort + "/");
log.info("ConnectionManager initialized with server: {}:{}", serverAddress, serverPort);
} catch (URISyntaxException e) {
log.error("Invalid server URI", e);
throw new RuntimeException("Invalid server URI configuration", e);
}
}
/**
* Add a connection state listener
*/
public void addListener(ConnectionStateListener listener) {
listeners.add(listener);
// Notify immediately of current state
listener.onStateChanged(state.get(), getStateMessage(state.get()));
}
/**
* Remove a connection state listener
*/
public void removeListener(ConnectionStateListener listener) {
listeners.remove(listener);
}
/**
* Start the connection process
*/
public void startConnection() {
if (state.get() == ConnectionState.CONNECTING || state.get() == ConnectionState.CONNECTED) {
log.info("Connection already in progress or established");
return;
}
setState(ConnectionState.CONNECTING, "Đang kết nối đến máy chủ...");
connectAsync();
}
/**
* Manual retry triggered by user
*/
public void retryNow() {
log.info("Manual retry requested");
// Cancel any pending automatic retry
cancelPendingReconnect();
// Reset retry delay for manual retry
currentRetryDelay.set(INITIAL_RETRY_DELAY_SECONDS);
// Start connection
setState(ConnectionState.RECONNECTING, "Đang thử kết nối lại...");
connectAsync();
}
/**
* Called when connection is established and handshake is complete
*/
public void onConnected() {
log.info("Connection established and handshake complete");
handshakeComplete = true;
reconnectAttempt.set(0);
currentRetryDelay.set(INITIAL_RETRY_DELAY_SECONDS);
setState(ConnectionState.CONNECTED, "Đã kết nối");
}
/**
* Called when connection is lost
*/
public void onDisconnected(String reason) {
log.warn("Connection lost: {}", reason);
handshakeComplete = false;
channel = null;
// Don't reconnect if we're already disconnected (intentional disconnect)
if (state.get() == ConnectionState.DISCONNECTED) {
return;
}
// Start reconnection process
scheduleReconnect();
}
/**
* Get current connection state
*/
public ConnectionState getState() {
return state.get();
}
/**
* Check if connected and ready to send packets
*/
public boolean isConnected() {
return state.get() == ConnectionState.CONNECTED &&
channel != null &&
channel.isActive() &&
handshakeComplete;
}
/**
* Get the current channel for sending packets
*/
public Channel getChannel() {
return channel;
}
/**
* Send a packet, throwing NetworkException if not connected
*/
public void sendPacket(Packet packet) throws NetworkException {
if (!isConnected()) {
throw new NetworkException("Không có kết nối đến máy chủ. Vui lòng đợi kết nối được khôi phục.");
}
try {
String text = PacketSerializer.GSON.toJson(packet);
channel.writeAndFlush(new TextWebSocketFrame(text));
} catch (Exception e) {
log.error("Failed to send packet", e);
throw new NetworkException("Lỗi gửi dữ liệu: " + e.getMessage());
}
}
/**
* Get the current server address
*/
public String getServerAddress() {
return serverAddress;
}
/**
* Get the current server port
*/
public int getServerPort() {
return serverPort;
}
// ==================== Private Methods ====================
private void connectAsync() {
new Thread(() -> {
try {
doConnect();
} catch (Exception e) {
log.error("Connection attempt failed", e);
scheduleReconnect();
}
}, "ConnectionManager-Connect").start();
}
private void doConnect() throws InterruptedException {
log.info("Attempting to connect to {}:{}", serverAddress, serverPort);
// Cleanup previous connection if any
if (eventLoopGroup != null && !eventLoopGroup.isShutdown()) {
eventLoopGroup.shutdownGracefully().sync();
}
eventLoopGroup = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap()
.group(eventLoopGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new HttpClientCodec())
.addLast(new HttpObjectAggregator(50 * 1024 * 1024))
.addLast(new WebSocketClientProtocolHandler(
serverUri,
WebSocketVersion.V13,
null,
true,
new DefaultHttpHeaders(),
50 * 1024 * 1024))
// MORE TOLERANT TIMEOUT SETTINGS:
// Reader timeout: 90 seconds - fallback disconnect if no data at all
// Writer timeout: 15 seconds - send ping every 15 seconds when idle
// Combined with MAX_MISSED_PONGS=4, this gives ~60 seconds tolerance
.addLast(new IdleStateHandler(READER_IDLE_SECONDS, WRITER_IDLE_SECONDS, 0))
.addLast("ws", ClientHandler.INSTANCE);
}
});
ChannelFuture connectFuture = bootstrap.connect(serverAddress, serverPort);
// Add listener for connection result
connectFuture.addListener(future -> {
if (future.isSuccess()) {
log.info("TCP connection established, waiting for WebSocket handshake...");
channel = connectFuture.channel();
// Add close listener
channel.closeFuture().addListener(closeFuture -> {
log.info("Channel closed");
if (state.get() != ConnectionState.DISCONNECTED) {
onDisconnected("Kết nối bị đóng");
}
});
} else {
log.error("Connection failed: {}", future.cause().getMessage());
scheduleReconnect();
}
});
} catch (Exception e) {
log.error("Error during connection setup", e);
scheduleReconnect();
}
}
private void scheduleReconnect() {
int attempt = reconnectAttempt.incrementAndGet();
int delay = currentRetryDelay.get();
log.info("Scheduling reconnect attempt {} in {} seconds", attempt, delay);
setState(ConnectionState.RECONNECTING,
String.format("Đang chờ kết nối lại (lần thử %d)...", attempt));
// Start countdown notifications
startCountdown(delay, attempt);
// Schedule the actual reconnect
reconnectFuture = scheduler.schedule(() -> {
cancelCountdown();
setState(ConnectionState.RECONNECTING, "Đang kết nối lại...");
connectAsync();
}, delay, TimeUnit.SECONDS);
// Calculate next delay with exponential backoff
int nextDelay = (int) Math.min(delay * BACKOFF_MULTIPLIER, MAX_RETRY_DELAY_SECONDS);
currentRetryDelay.set(nextDelay);
}
private void startCountdown(int seconds, int attemptNumber) {
final AtomicInteger remaining = new AtomicInteger(seconds);
countdownFuture = scheduler.scheduleAtFixedRate(() -> {
int r = remaining.getAndDecrement();
if (r > 0) {
notifyCountdown(r, attemptNumber);
}
}, 0, 1, TimeUnit.SECONDS);
}
private void cancelCountdown() {
if (countdownFuture != null && !countdownFuture.isDone()) {
countdownFuture.cancel(false);
}
}
private void cancelPendingReconnect() {
cancelCountdown();
if (reconnectFuture != null && !reconnectFuture.isDone()) {
reconnectFuture.cancel(false);
}
}
private void setState(ConnectionState newState, String message) {
ConnectionState oldState = state.getAndSet(newState);
if (oldState != newState) {
log.info("Connection state changed: {} -> {} ({})", oldState, newState, message);
notifyStateChange(newState, message);
}
}
private void notifyStateChange(ConnectionState newState, String message) {
SwingUtilities.invokeLater(() -> {
for (ConnectionStateListener listener : listeners) {
try {
listener.onStateChanged(newState, message);
} catch (Exception e) {
log.error("Error notifying listener", e);
}
}
});
}
private void notifyCountdown(int secondsRemaining, int attemptNumber) {
SwingUtilities.invokeLater(() -> {
for (ConnectionStateListener listener : listeners) {
try {
listener.onReconnectCountdown(secondsRemaining, attemptNumber);
} catch (Exception e) {
log.error("Error notifying countdown", e);
}
}
});
}
private String getStateMessage(ConnectionState state) {
return switch (state) {
case DISCONNECTED -> "Chưa kết nối";
case CONNECTING -> "Đang kết nối...";
case CONNECTED -> "Đã kết nối";
case RECONNECTING -> "Đang kết nối lại...";
};
}
/**
* Shutdown the connection manager (for application exit)
* Sends a proper WebSocket close frame so the server can clean up the session
*/
public void shutdown() {
log.info("Shutting down ConnectionManager");
state.set(ConnectionState.DISCONNECTED);
cancelPendingReconnect();
if (channel != null && channel.isActive()) {
try {
// Send WebSocket close frame for proper server-side cleanup
log.info("Sending WebSocket close frame to server...");
channel.writeAndFlush(new io.netty.handler.codec.http.websocketx.CloseWebSocketFrame())
.sync(); // Wait for close frame to be sent
// Give server a moment to process the close
Thread.sleep(100);
// Now close the channel
channel.close().sync();
log.info("Channel closed cleanly");
} catch (InterruptedException e) {
log.warn("Interrupted while closing channel", e);
Thread.currentThread().interrupt();
// Force close if interrupted
channel.close();
} catch (Exception e) {
log.warn("Error during graceful close, forcing close", e);
channel.close();
}
}
if (eventLoopGroup != null && !eventLoopGroup.isShutdown()) {
try {
eventLoopGroup.shutdownGracefully().sync();
} catch (InterruptedException e) {
log.warn("Interrupted while shutting down event loop", e);
Thread.currentThread().interrupt();
}
}
scheduler.shutdown();
}
}
@@ -1075,7 +1075,7 @@ private void setupDebounceSearch() {
try {
Date parsedDate;
if (patientDob.matches("\\d+")) { // Check if the string is a timestamp
if (patientDob.matches("-?\\d+")) { // Check if the string is a timestamp
long timestamp = Long.parseLong(patientDob); // Convert the string to a long
parsedDate = new Date(timestamp); // Convert the timestamp to a Date
} else {
@@ -0,0 +1,289 @@
package BsK.client.ui.component.common;
import BsK.client.network.ConnectionManager;
import BsK.client.network.ConnectionManager.ConnectionState;
import BsK.client.network.ConnectionManager.ConnectionStateListener;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.geom.Arc2D;
/**
* Glass pane overlay that blocks UI interaction when connection is lost.
* Shows connection status, retry countdown, and manual retry button.
*/
public class ConnectionStatusOverlay extends JPanel implements ConnectionStateListener {
private static final Color OVERLAY_COLOR = new Color(0, 0, 0, 180);
private static final Color CARD_BACKGROUND = new Color(45, 45, 48);
private static final Color TEXT_COLOR = new Color(230, 230, 230);
private static final Color ACCENT_COLOR = new Color(0, 122, 204);
private static final Color BUTTON_HOVER_COLOR = new Color(0, 150, 230);
private final JLabel statusLabel;
private final JLabel messageLabel;
private final JLabel countdownLabel;
private final JButton retryButton;
private final SpinnerPanel spinnerPanel;
private volatile boolean showSpinner = true;
private volatile int countdownSeconds = 0;
private volatile int attemptNumber = 0;
public ConnectionStatusOverlay() {
setOpaque(false);
setLayout(new GridBagLayout());
// Block all mouse events on the overlay
addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent e) {
e.consume();
}
@Override
public void mousePressed(MouseEvent e) {
e.consume();
}
});
addMouseMotionListener(new MouseAdapter() {});
// Create center card panel
JPanel cardPanel = createCardPanel();
add(cardPanel);
// Initialize components
spinnerPanel = new SpinnerPanel();
statusLabel = createLabel("Đang kết nối...", 18, Font.BOLD);
messageLabel = createLabel("Vui lòng đợi trong giây lát", 14, Font.PLAIN);
countdownLabel = createLabel("", 12, Font.PLAIN);
countdownLabel.setForeground(new Color(180, 180, 180));
retryButton = createRetryButton();
// Layout components in card
cardPanel.setLayout(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
gbc.gridx = 0;
gbc.gridy = 0;
gbc.insets = new Insets(10, 20, 10, 20);
cardPanel.add(spinnerPanel, gbc);
gbc.gridy = 1;
gbc.insets = new Insets(15, 20, 5, 20);
cardPanel.add(statusLabel, gbc);
gbc.gridy = 2;
gbc.insets = new Insets(5, 20, 5, 20);
cardPanel.add(messageLabel, gbc);
gbc.gridy = 3;
gbc.insets = new Insets(5, 20, 10, 20);
cardPanel.add(countdownLabel, gbc);
gbc.gridy = 4;
gbc.insets = new Insets(10, 20, 20, 20);
cardPanel.add(retryButton, gbc);
// Initially hidden until state is set
retryButton.setVisible(false);
countdownLabel.setVisible(false);
// Register as listener
ConnectionManager.getInstance().addListener(this);
}
private JPanel createCardPanel() {
JPanel card = new JPanel() {
@Override
protected void paintComponent(Graphics g) {
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(CARD_BACKGROUND);
g2.fillRoundRect(0, 0, getWidth(), getHeight(), 20, 20);
g2.dispose();
}
};
card.setOpaque(false);
card.setPreferredSize(new Dimension(400, 280));
return card;
}
private JLabel createLabel(String text, int size, int style) {
JLabel label = new JLabel(text);
label.setFont(new Font("Segoe UI", style, size));
label.setForeground(TEXT_COLOR);
label.setHorizontalAlignment(SwingConstants.CENTER);
return label;
}
private JButton createRetryButton() {
JButton button = new JButton("Thử lại ngay") {
@Override
protected void paintComponent(Graphics g) {
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
if (getModel().isPressed()) {
g2.setColor(ACCENT_COLOR.darker());
} else if (getModel().isRollover()) {
g2.setColor(BUTTON_HOVER_COLOR);
} else {
g2.setColor(ACCENT_COLOR);
}
g2.fillRoundRect(0, 0, getWidth(), getHeight(), 10, 10);
g2.setColor(Color.WHITE);
g2.setFont(getFont());
FontMetrics fm = g2.getFontMetrics();
int x = (getWidth() - fm.stringWidth(getText())) / 2;
int y = (getHeight() + fm.getAscent() - fm.getDescent()) / 2;
g2.drawString(getText(), x, y);
g2.dispose();
}
};
button.setFont(new Font("Segoe UI", Font.BOLD, 14));
button.setForeground(Color.WHITE);
button.setPreferredSize(new Dimension(150, 40));
button.setBorderPainted(false);
button.setContentAreaFilled(false);
button.setFocusPainted(false);
button.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
button.addActionListener(e -> {
ConnectionManager.getInstance().retryNow();
});
return button;
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
g2.setColor(OVERLAY_COLOR);
g2.fillRect(0, 0, getWidth(), getHeight());
g2.dispose();
}
@Override
public void onStateChanged(ConnectionState newState, String message) {
SwingUtilities.invokeLater(() -> {
switch (newState) {
case DISCONNECTED -> {
setVisible(true);
statusLabel.setText("Chưa kết nối");
messageLabel.setText("Ứng dụng đang chờ kết nối đến máy chủ");
showSpinner = false;
retryButton.setVisible(true);
countdownLabel.setVisible(false);
}
case CONNECTING -> {
setVisible(true);
statusLabel.setText("Đang kết nối...");
messageLabel.setText(message);
showSpinner = true;
retryButton.setVisible(false);
countdownLabel.setVisible(false);
}
case CONNECTED -> {
setVisible(false);
showSpinner = false;
}
case RECONNECTING -> {
setVisible(true);
statusLabel.setText("Đang kết nối lại...");
messageLabel.setText(message);
showSpinner = true;
retryButton.setVisible(true);
countdownLabel.setVisible(true);
}
}
spinnerPanel.setSpinning(showSpinner);
repaint();
});
}
@Override
public void onReconnectCountdown(int secondsRemaining, int attemptNumber) {
SwingUtilities.invokeLater(() -> {
this.countdownSeconds = secondsRemaining;
this.attemptNumber = attemptNumber;
countdownLabel.setText(String.format("Thử lại sau %d giây (lần thử %d)",
secondsRemaining, attemptNumber));
countdownLabel.setVisible(true);
});
}
/**
* Inner panel that shows a spinning animation
*/
private class SpinnerPanel extends JPanel {
private volatile boolean spinning = true;
private int angle = 0;
private Timer timer;
public SpinnerPanel() {
setOpaque(false);
setPreferredSize(new Dimension(60, 60));
timer = new Timer(30, e -> {
if (spinning) {
angle = (angle + 10) % 360;
repaint();
}
});
timer.start();
}
public void setSpinning(boolean spinning) {
this.spinning = spinning;
repaint();
}
@Override
protected void paintComponent(Graphics g) {
super.paintComponent(g);
Graphics2D g2 = (Graphics2D) g.create();
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
int size = 50;
int x = (getWidth() - size) / 2;
int y = (getHeight() - size) / 2;
if (spinning) {
// Draw spinning arc
g2.setStroke(new BasicStroke(4, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
g2.setColor(new Color(80, 80, 85));
g2.drawOval(x, y, size, size);
g2.setColor(ACCENT_COLOR);
g2.draw(new Arc2D.Double(x, y, size, size, angle, 90, Arc2D.OPEN));
} else {
// Draw static circle with X
g2.setStroke(new BasicStroke(3));
g2.setColor(new Color(200, 80, 80));
g2.drawOval(x, y, size, size);
int padding = 15;
g2.drawLine(x + padding, y + padding, x + size - padding, y + size - padding);
g2.drawLine(x + size - padding, y + padding, x + padding, y + size - padding);
}
g2.dispose();
}
}
/**
* Cleanup when overlay is no longer needed
*/
public void cleanup() {
ConnectionManager.getInstance().removeListener(this);
}
}
@@ -773,7 +773,7 @@ public class HistoryViewDialog extends JDialog {
private String formatDate(String dateStr) {
try {
if (dateStr != null && dateStr.matches("\\d+")) {
if (dateStr != null && dateStr.matches("-?\\d+")) {
// Timestamp - Use Vietnam timezone (UTC+7) for consistent display
Date date = new Date(Long.parseLong(dateStr));
return DateUtils.createVietnamDateFormat("dd/MM/yyyy HH:mm").format(date);
@@ -0,0 +1,24 @@
package BsK.common.exception;
/**
* Unchecked exception thrown when a network operation fails.
* Contains a user-friendly message suitable for display in dialogs or toasts.
*/
public class NetworkException extends RuntimeException {
public NetworkException(String message) {
super(message);
}
public NetworkException(String message, Throwable cause) {
super(message, cause);
}
/**
* Get a user-friendly message suitable for display
*/
public String getUserMessage() {
return getMessage();
}
}
@@ -55,7 +55,7 @@ public class DateUtils {
try {
// Check if it's a timestamp (all digits)
if (dateInput.matches("\\d+")) {
if (dateInput.matches("-?\\d+")) {
Date date = new Date(Long.parseLong(dateInput));
synchronized (DISPLAY_FORMAT) {
return DISPLAY_FORMAT.format(date);
@@ -90,7 +90,7 @@ public class DateUtils {
try {
// Check if it's a timestamp (all digits)
if (dateInput.matches("\\d+")) {
if (dateInput.matches("-?\\d+")) {
return new Date(Long.parseLong(dateInput));
} else {
// Try to parse as dd/MM/yyyy format using Vietnam timezone